diff --git a/cMake/FreeCAD_Helpers/SetupQt.cmake b/cMake/FreeCAD_Helpers/SetupQt.cmake index 34c454adba..bbe1316b0a 100644 --- a/cMake/FreeCAD_Helpers/SetupQt.cmake +++ b/cMake/FreeCAD_Helpers/SetupQt.cmake @@ -10,6 +10,10 @@ if(BUILD_GUI) if (FREECAD_QT_MAJOR_VERSION EQUAL 6) list (APPEND FREECAD_QT_COMPONENTS GuiTools) list (APPEND FREECAD_QT_COMPONENTS SvgWidgets) + elseif (FREECAD_QT_MAJOR_VERSION EQUAL 5) + if (WIN32) + list (APPEND FREECAD_QT_COMPONENTS WinExtras) + endif() endif() list (APPEND FREECAD_QT_COMPONENTS OpenGL PrintSupport Svg UiTools Widgets) if (BUILD_WEB) diff --git a/src/3rdParty/libE57Format/src/CompressedVectorWriterImpl.cpp b/src/3rdParty/libE57Format/src/CompressedVectorWriterImpl.cpp index 7f5b30690e..c57da72d63 100644 --- a/src/3rdParty/libE57Format/src/CompressedVectorWriterImpl.cpp +++ b/src/3rdParty/libE57Format/src/CompressedVectorWriterImpl.cpp @@ -342,6 +342,7 @@ namespace e57 /// zero after write, if have too much data) } +#ifdef E57_MAX_VERBOSE ///??? useful? /// Get approximation of number of bytes per record of CompressedVector /// and total of bytes used @@ -351,7 +352,6 @@ namespace e57 totalBitsPerRecord += bytestream->bitsPerRecord(); } -#ifdef E57_MAX_VERBOSE const float totalBytesPerRecord = std::max( totalBitsPerRecord / 8, 0.1F ); //??? trust std::cout << " totalBytesPerRecord=" << totalBytesPerRecord << std::endl; //??? diff --git a/src/App/CMakeLists.txt b/src/App/CMakeLists.txt index 02ebe92de2..918ced0a6f 100644 --- a/src/App/CMakeLists.txt +++ b/src/App/CMakeLists.txt @@ -261,6 +261,7 @@ SET(FreeCADApp_CPP_SRCS ComplexGeoData.cpp ComplexGeoDataPyImp.cpp Enumeration.cpp + IndexedName.cpp Material.cpp MaterialPyImp.cpp Metadata.cpp @@ -277,6 +278,7 @@ SET(FreeCADApp_HPP_SRCS ColorModel.h ComplexGeoData.h Enumeration.h + IndexedName.h Material.h Metadata.h ) diff --git a/src/App/Document.h b/src/App/Document.h index f09a7310d0..ad202a31d3 100644 --- a/src/App/Document.h +++ b/src/App/Document.h @@ -71,13 +71,6 @@ public: IgnoreErrorOnRecompute = 12, // Don't report errors if the recompute failed }; - enum class NotificationType { - Information, - Warning, - Error, - Critical, - }; - /** @name Properties */ //@{ /// holds the long name of the document (utf-8 coded) @@ -177,8 +170,6 @@ public: boost::signals2::signal&)> signalSkipRecompute; boost::signals2::signal signalFinishRestoreObject; boost::signals2::signal signalChangePropertyEditor; - // signal user message - boost::signals2::signal signalUserMessage; //@} boost::signals2::signal signalLinkXsetValue; diff --git a/src/App/Expression.cpp b/src/App/Expression.cpp index 786de7a870..6b253e1619 100644 --- a/src/App/Expression.cpp +++ b/src/App/Expression.cpp @@ -106,17 +106,19 @@ FC_LOG_LEVEL_INIT("Expression", true, true) _e.raiseException();\ }while(0) -#define EXPR_PY_THROW(_expr) _EXPR_PY_THROW("",_expr) +#define EXPR_PY_THROW(_expr) _EXPR_PY_THROW("", _expr) -#define EXPR_THROW(_msg) _EXPR_THROW(_msg,this) +#define EXPR_THROW(_msg) _EXPR_THROW(_msg, this) -#define RUNTIME_THROW(_msg) __EXPR_THROW(Base::RuntimeError,_msg, static_cast(nullptr)) +#define ARGUMENT_THROW(_msg) EXPR_THROW("Invalid number of arguments: " _msg) -#define TYPE_THROW(_msg) __EXPR_THROW(Base::TypeError,_msg, static_cast(nullptr)) +#define RUNTIME_THROW(_msg) __EXPR_THROW(Base::RuntimeError, _msg, static_cast(nullptr)) -#define PARSER_THROW(_msg) __EXPR_THROW(Base::ParserError,_msg, static_cast(nullptr)) +#define TYPE_THROW(_msg) __EXPR_THROW(Base::TypeError, _msg, static_cast(nullptr)) -#define PY_THROW(_msg) __EXPR_THROW(Py::RuntimeError,_msg, static_cast(nullptr)) +#define PARSER_THROW(_msg) __EXPR_THROW(Base::ParserError, _msg, static_cast(nullptr)) + +#define PY_THROW(_msg) __EXPR_THROW(Py::RuntimeError, _msg, static_cast(nullptr)) static inline std::ostream &operator<<(std::ostream &os, const App::Expression *expr) { if(expr) { @@ -1729,60 +1731,91 @@ FunctionExpression::FunctionExpression(const DocumentObject *_owner, Function _f , args(_args) { switch (f) { + case ABS: case ACOS: case ASIN: case ATAN: - case ABS: - case EXP: - case LOG: - case LOG10: - case SIN: - case SINH: - case TAN: - case TANH: - case SQRT: case CBRT: + case CEIL: case COS: case COSH: - case ROUND: - case TRUNC: - case CEIL: + case EXP: case FLOOR: - case MINVERT: - case STR: case HIDDENREF: case HREF: + case LOG: + case LOG10: + case MINVERT: + case ROTATIONX: + case ROTATIONY: + case ROTATIONZ: + case ROUND: + case SIN: + case SINH: + case SQRT: + case STR: + case TAN: + case TANH: + case TRUNC: if (args.size() != 1) - EXPR_THROW("Invalid number of arguments: exactly one required."); + ARGUMENT_THROW("exactly one required."); + break; + case PLACEMENT: + if (args.size() > 3) + ARGUMENT_THROW("exactly one, two, or three required."); + break; + case TRANSLATIONM: + if (args.size() != 1 && args.size() != 3) + ARGUMENT_THROW("exactly one or three required."); break; - case MOD: case ATAN2: + case MOD: + case MROTATEX: + case MROTATEY: + case MROTATEZ: case POW: if (args.size() != 2) - EXPR_THROW("Invalid number of arguments: exactly two required."); + ARGUMENT_THROW("exactly two required."); break; - case HYPOT: case CATH: + case HYPOT: + case ROTATION: if (args.size() < 2 || args.size() > 3) - EXPR_THROW("Invalid number of arguments: exactly two, or three required."); + ARGUMENT_THROW("exactly two, or three required."); + break; + case MTRANSLATE: + case MSCALE: + if (args.size() != 2 && args.size() != 4) + ARGUMENT_THROW("exactly two or four required."); + break; + case MROTATE: + if (args.size() < 2 || args.size() > 4) + ARGUMENT_THROW("exactly two, three, or four required."); + break; + case VECTOR: + if (args.size() != 3) + ARGUMENT_THROW("exactly three required."); + break; + case MATRIX: + if (args.size() > 16) + ARGUMENT_THROW("exactly 16 or less required."); break; - case STDDEV: - case SUM: case AVERAGE: case COUNT: - case MIN: - case MAX: case CREATE: - case MSCALE: + case MAX: + case MIN: + case STDDEV: + case SUM: if (args.empty()) - EXPR_THROW("Invalid number of arguments: at least one required."); + ARGUMENT_THROW("at least one required."); break; case LIST: case TUPLE: break; - case NONE: case AGGREGATES: case LAST: + case NONE: default: PARSER_THROW("Unknown function"); break; @@ -1998,6 +2031,76 @@ Py::Object FunctionExpression::evalAggregate( return pyFromQuantity(c->getQuantity()); } +Base::Vector3d FunctionExpression::evaluateSecondVectorArgument(const Expression *expression, const std::vector &arguments) +{ + Py::Tuple vectorValues; + Py::Object secondParameter = arguments[1]->getPyValue(); + + if (arguments.size() == 2) { + if (!secondParameter.isSequence()) + _EXPR_THROW("Second parameter is not a sequence type: '" << secondParameter.as_string() << "'.", expression); + if (PySequence_Size(secondParameter.ptr()) != 3) + _EXPR_THROW("Second parameter provided has " << PySequence_Size(secondParameter.ptr()) << " elements instead of 3.", expression); + + vectorValues = Py::Tuple(Py::Sequence(secondParameter)); + } else { + vectorValues = Py::Tuple(3); + vectorValues.setItem(0, secondParameter); + vectorValues.setItem(1, arguments[2]->getPyValue()); + vectorValues.setItem(2, arguments[3]->getPyValue()); + } + + Vector3d vector; + if (!PyArg_ParseTuple(vectorValues.ptr(), "ddd", &vector.x, &vector.y, &vector.z)) { + PyErr_Clear(); + _EXPR_THROW("Error parsing scale values.", expression); + } + + return vector; +} + +void FunctionExpression::initialiseObject(const Py::Object *object, const std::vector &arguments, const unsigned long offset) +{ + if (arguments.size() > offset) { + Py::Tuple constructorArguments(arguments.size() - offset); + for (unsigned i = offset; i < arguments.size(); ++i) + constructorArguments.setItem(i - offset, arguments[i]->getPyValue()); + Py::Dict kwd; + PyObjectBase::__PyInit(object->ptr(), constructorArguments.ptr(), kwd.ptr()); + } +} + +Py::Object FunctionExpression::transformFirstArgument( + const Expression* expression, + const std::vector &arguments, + const Base::Matrix4D* transformationMatrix +) +{ + Py::Object target = arguments[0]->getPyValue(); + + if (PyObject_TypeCheck(target.ptr(), &Base::MatrixPy::Type)) { + Base::Matrix4D matrix = static_cast(target.ptr())->value(); + return Py::asObject(new Base::MatrixPy(*transformationMatrix * matrix)); + } else if (PyObject_TypeCheck(target.ptr(), &Base::PlacementPy::Type)) { + Base::Matrix4D placementMatrix = + static_cast(target.ptr())->getPlacementPtr()->toMatrix(); + return Py::asObject(new Base::PlacementPy(Base::Placement(*transformationMatrix * placementMatrix))); + } else if (PyObject_TypeCheck(target.ptr(), &Base::RotationPy::Type)) { + Base::Matrix4D rotatioMatrix; + static_cast(target.ptr())->getRotationPtr()->getValue(rotatioMatrix); + return Py::asObject(new Base::RotationPy(Base::Rotation(*transformationMatrix * rotatioMatrix))); + } + + _EXPR_THROW("Function requires the first argument to be either Matrix, Placement or Rotation.", expression); +} + +Py::Object FunctionExpression::translationMatrix(double x, double y, double z) +{ + Base::Matrix4D matrix; + matrix.move(x, y, z); + return Py::asObject(new Base::MatrixPy(matrix)); +} + Py::Object FunctionExpression::evaluate(const Expression *expr, int f, const std::vector &args) { if(!expr || !expr->getOwner()) @@ -2007,103 +2110,163 @@ Py::Object FunctionExpression::evaluate(const Expression *expr, int f, const std if (f > AGGREGATES) return evalAggregate(expr, f, args); - if(f == LIST) { - if(args.size() == 1 && args[0]->isDerivedFrom(RangeExpression::getClassTypeId())) + switch (f) { + case LIST: { + if (args.size() == 1 && args[0]->isDerivedFrom(RangeExpression::getClassTypeId())) return args[0]->getPyValue(); Py::List list(args.size()); - int i=0; - for(auto &arg : args) - list.setItem(i++,arg->getPyValue()); + int i = 0; + for (auto &arg : args) + list.setItem(i++, arg->getPyValue()); return list; - } else if (f == TUPLE) { - if(args.size() == 1 && args[0]->isDerivedFrom(RangeExpression::getClassTypeId())) + } + case TUPLE: { + if (args.size() == 1 && args[0]->isDerivedFrom(RangeExpression::getClassTypeId())) return Py::Tuple(args[0]->getPyValue()); Py::Tuple tuple(args.size()); - int i=0; - for(auto &arg : args) - tuple.setItem(i++,arg->getPyValue()); + int i = 0; + for (auto &arg : args) + tuple.setItem(i++, arg->getPyValue()); return tuple; - } else if (f == MSCALE) { - if(args.size() < 2) - _EXPR_THROW("Function requires at least two arguments.",expr); - Py::Object pymat = args[0]->getPyValue(); - Py::Object pyscale; - if(PyObject_TypeCheck(pymat.ptr(),&Base::MatrixPy::Type)) { - if(args.size() == 2) { - Py::Object obj = args[1]->getPyValue(); - if(obj.isSequence() && PySequence_Size(obj.ptr())==3) - pyscale = Py::Tuple(Py::Sequence(obj)); - } else if(args.size() == 4) { - Py::Tuple tuple(3); - tuple.setItem(0,args[1]->getPyValue()); - tuple.setItem(1,args[2]->getPyValue()); - tuple.setItem(2,args[3]->getPyValue()); - pyscale = tuple; - } - } - if(!pyscale.isNone()) { - Base::Vector3d vec; - if (!PyArg_ParseTuple(pyscale.ptr(), "ddd", &vec.x,&vec.y,&vec.z)) - PyErr_Clear(); - else { - auto mat = static_cast(pymat.ptr())->value(); - mat.scale(vec); - return Py::asObject(new Base::MatrixPy(mat)); - } - } - _EXPR_THROW("Function requires arguments to be either " - "(matrix,vector) or (matrix,number,number,number).", expr); + } } if(args.empty()) _EXPR_THROW("Function requires at least one argument.",expr); - if (f == MINVERT) { + switch (f) { + case MINVERT: { Py::Object pyobj = args[0]->getPyValue(); - if (PyObject_TypeCheck(pyobj.ptr(),&Base::MatrixPy::Type)) { + if (PyObject_TypeCheck(pyobj.ptr(), &Base::MatrixPy::Type)) { auto m = static_cast(pyobj.ptr())->value(); if (fabs(m.determinant()) <= DBL_EPSILON) - _EXPR_THROW("Cannot invert singular matrix.",expr); + _EXPR_THROW("Cannot invert singular matrix.", expr); m.inverseGauss(); return Py::asObject(new Base::MatrixPy(m)); - - } else if (PyObject_TypeCheck(pyobj.ptr(),&Base::PlacementPy::Type)) { + } else if (PyObject_TypeCheck(pyobj.ptr(), &Base::PlacementPy::Type)) { const auto &pla = *static_cast(pyobj.ptr())->getPlacementPtr(); return Py::asObject(new Base::PlacementPy(pla.inverse())); - - } else if (PyObject_TypeCheck(pyobj.ptr(),&Base::RotationPy::Type)) { + } else if (PyObject_TypeCheck(pyobj.ptr(), &Base::RotationPy::Type)) { const auto &rot = *static_cast(pyobj.ptr())->getRotationPtr(); return Py::asObject(new Base::RotationPy(rot.inverse())); } - _EXPR_THROW("Function requires the first argument to be either Matrix, Placement or Rotation.",expr); + _EXPR_THROW( + "Function requires the first argument to be either Matrix, Placement or Rotation.", + expr); + break; + } + case MROTATE: { + Py::Object rotationObject = args[1]->getPyValue(); + if (!PyObject_TypeCheck(rotationObject.ptr(), &Base::RotationPy::Type)) + { + rotationObject = Py::asObject(new Base::RotationPy(Base::Rotation())); + initialiseObject(&rotationObject, args, 1); + } - } else if (f == CREATE) { + Base::Matrix4D rotationMatrix; + static_cast(rotationObject.ptr())->getRotationPtr()->getValue(rotationMatrix); + + return transformFirstArgument(expr, args, &rotationMatrix); + } + case MROTATEX: + case MROTATEY: + case MROTATEZ: + { + Py::Object rotationAngleParameter = args[1]->getPyValue(); + Quantity rotationAngle = pyToQuantity(rotationAngleParameter, expr, "Invalid rotation angle."); + + if (!(rotationAngle.isDimensionlessOrUnit(Unit::Angle))) + _EXPR_THROW("Unit must be either empty or an angle.", expr); + + Rotation rotation = Base::Rotation( + Vector3d(static_cast(f == MROTATEX), static_cast(f == MROTATEY), static_cast(f == MROTATEZ)), + rotationAngle.getValue() * M_PI / 180.0); + Base::Matrix4D rotationMatrix; + rotation.getValue(rotationMatrix); + + return transformFirstArgument(expr, args, &rotationMatrix); + } + case MSCALE: { + Vector3d scaleValues = evaluateSecondVectorArgument(expr, args); + + Base::Matrix4D scaleMatrix; + scaleMatrix.scale(scaleValues); + + return transformFirstArgument(expr, args, &scaleMatrix); + } + case MTRANSLATE: { + Vector3d translateValues = evaluateSecondVectorArgument(expr, args); + + Base::Matrix4D translateMatrix; + translateMatrix.move(translateValues); + + Py::Object target = args[0]->getPyValue(); + if (PyObject_TypeCheck(target.ptr(), &Base::RotationPy::Type)) { + Base::Matrix4D targetRotatioMatrix; + static_cast(target.ptr())->getRotationPtr()->getValue(targetRotatioMatrix); + return Py::asObject(new Base::PlacementPy(Base::Placement(translateMatrix * targetRotatioMatrix))); + } + + return transformFirstArgument(expr, args, &translateMatrix); + } + case CREATE: { Py::Object pytype = args[0]->getPyValue(); - if(!pytype.isString()) - _EXPR_THROW("Function requires the first argument to be a string.",expr); + if (!pytype.isString()) + _EXPR_THROW("Function requires the first argument to be a string.", expr); std::string type(pytype.as_string()); Py::Object res; - if(boost::iequals(type,"matrix")) + if (boost::iequals(type, "matrix")) res = Py::asObject(new Base::MatrixPy(Base::Matrix4D())); - else if(boost::iequals(type,"vector")) + else if (boost::iequals(type, "vector")) res = Py::asObject(new Base::VectorPy(Base::Vector3d())); - else if(boost::iequals(type,"placement")) + else if (boost::iequals(type, "placement")) res = Py::asObject(new Base::PlacementPy(Base::Placement())); - else if(boost::iequals(type,"rotation")) + else if (boost::iequals(type, "rotation")) res = Py::asObject(new Base::RotationPy(Base::Rotation())); else - _EXPR_THROW("Unknown type '" << type << "'.",expr); - if(args.size()>1) { - Py::Tuple tuple(args.size()-1); - for(unsigned i=1;igetPyValue()); - Py::Dict dict; - PyObjectBase::__PyInit(res.ptr(),tuple.ptr(),dict.ptr()); - } + _EXPR_THROW("Unknown type '" << type << "'.", expr); + initialiseObject(&res, args, 1); return res; - } else if (f == STR) { + } + case MATRIX: { + Py::Object matrix = Py::asObject(new Base::MatrixPy(Base::Matrix4D())); + initialiseObject(&matrix, args); + return matrix; + } + case PLACEMENT: { + Py::Object placement = Py::asObject(new Base::PlacementPy(Base::Placement())); + initialiseObject(&placement, args); + return placement; + } + case ROTATION: { + Py::Object rotation = Py::asObject(new Base::RotationPy(Base::Rotation())); + initialiseObject(&rotation, args); + return rotation; + } + case STR: return Py::String(args[0]->getPyValue().as_string()); - } else if (f == HIDDENREF || f == HREF) { + case TRANSLATIONM: { + if (args.size() != 1) + break; // Break and proceed to 3 size version. + Py::Object parameter = args[0]->getPyValue(); + if (!parameter.isSequence()) + _EXPR_THROW("Not sequence type: '" << parameter.as_string() << "'.", expr); + if (PySequence_Size(parameter.ptr()) != 3) + _EXPR_THROW("Sequence provided has " << PySequence_Size(parameter.ptr()) << " elements instead of 3.", expr); + double x, y, z; + if (!PyArg_ParseTuple(Py::Tuple(Py::Sequence(parameter)).ptr(), "ddd", &x, &y, &z)) { + PyErr_Clear(); + _EXPR_THROW("Error parsing sequence.", expr); + } + return translationMatrix(x, y, z); + } + case VECTOR: { + Py::Object vector = Py::asObject(new Base::VectorPy(Base::Vector3d())); + initialiseObject(&vector, args); + return vector; + } + case HIDDENREF: + case HREF: return args[0]->getPyValue(); } @@ -2111,13 +2274,13 @@ Py::Object FunctionExpression::evaluate(const Expression *expr, int f, const std Quantity v1 = pyToQuantity(e1,expr,"Invalid first argument."); Py::Object e2; Quantity v2; - if(args.size()>1) { + if (args.size() > 1) { e2 = args[1]->getPyValue(); v2 = pyToQuantity(e2,expr,"Invalid second argument."); } Py::Object e3; Quantity v3; - if(args.size()>2) { + if (args.size() > 2) { e3 = args[2]->getPyValue(); v3 = pyToQuantity(e3,expr,"Invalid third argument."); } @@ -2133,8 +2296,11 @@ Py::Object FunctionExpression::evaluate(const Expression *expr, int f, const std case COS: case SIN: case TAN: - if (!(v1.getUnit() == Unit::Angle || v1.getUnit().isEmpty())) - _EXPR_THROW("Unit must be either empty or an angle.",expr); + case ROTATIONX: + case ROTATIONY: + case ROTATIONZ: + if (!(v1.isDimensionlessOrUnit(Unit::Angle))) + _EXPR_THROW("Unit must be either empty or an angle.", expr); // Convert value to radians value *= M_PI / 180.0; @@ -2143,8 +2309,8 @@ Py::Object FunctionExpression::evaluate(const Expression *expr, int f, const std case ACOS: case ASIN: case ATAN: - if (!v1.getUnit().isEmpty()) - _EXPR_THROW("Unit must be empty.",expr); + if (!v1.isDimensionless()) + _EXPR_THROW("Unit must be empty.", expr); unit = Unit::Angle; scaler = 180.0 / M_PI; break; @@ -2154,7 +2320,7 @@ Py::Object FunctionExpression::evaluate(const Expression *expr, int f, const std case SINH: case TANH: case COSH: - if (!v1.getUnit().isEmpty()) + if (!v1.isDimensionless()) _EXPR_THROW("Unit must be empty.",expr); unit = Unit(); break; @@ -2233,12 +2399,12 @@ Py::Object FunctionExpression::evaluate(const Expression *expr, int f, const std if (e2.isNone()) _EXPR_THROW("Invalid second argument.",expr); - if (!v2.getUnit().isEmpty()) + if (!v2.isDimensionless()) _EXPR_THROW("Exponent is not allowed to have a unit.",expr); // Compute new unit for exponentiation double exponent = v2.getValue(); - if (!v1.getUnit().isEmpty()) { + if (!v1.isDimensionless()) { if (exponent - boost::math::round(exponent) < 1e-9) unit = v1.getUnit().pow(exponent); else @@ -2261,6 +2427,10 @@ Py::Object FunctionExpression::evaluate(const Expression *expr, int f, const std } unit = v1.getUnit(); break; + case TRANSLATIONM: + if (v1.isDimensionlessOrUnit(Unit::Length) && v2.isDimensionlessOrUnit(Unit::Length) && v3.isDimensionlessOrUnit(Unit::Length)) + break; + _EXPR_THROW("Translation units must be a length or dimensionless.", expr); default: _EXPR_THROW("Unknown function: " << f,0); } @@ -2344,6 +2514,14 @@ Py::Object FunctionExpression::evaluate(const Expression *expr, int f, const std case FLOOR: output = floor(value); break; + case ROTATIONX: + case ROTATIONY: + case ROTATIONZ: + return Py::asObject(new Base::RotationPy(Base::Rotation( + Vector3d(static_cast(f == ROTATIONX), static_cast(f == ROTATIONY), static_cast(f == ROTATIONZ)), + value))); + case TRANSLATIONM: + return translationMatrix(v1.getValue(), v2.getValue(), v3.getValue()); default: _EXPR_THROW("Unknown function: " << f,0); } @@ -2397,82 +2575,108 @@ Expression *FunctionExpression::simplify() const void FunctionExpression::_toString(std::ostream &ss, bool persistent,int) const { switch (f) { + case ABS: + ss << "abs("; break;; case ACOS: ss << "acos("; break;; case ASIN: ss << "asin("; break;; case ATAN: ss << "atan("; break;; - case ABS: - ss << "abs("; break;; - case EXP: - ss << "exp("; break;; - case LOG: - ss << "log("; break;; - case LOG10: - ss << "log10("; break;; - case SIN: - ss << "sin("; break;; - case SINH: - ss << "sinh("; break;; - case TAN: - ss << "tan("; break;; - case TANH: - ss << "tanh("; break;; - case SQRT: - ss << "sqrt("; break;; + case ATAN2: + ss << "atan2("; break;; + case CATH: + ss << "cath("; break;; case CBRT: ss << "cbrt("; break;; + case CEIL: + ss << "ceil("; break;; case COS: ss << "cos("; break;; case COSH: ss << "cosh("; break;; - case MOD: - ss << "mod("; break;; - case ATAN2: - ss << "atan2("; break;; - case POW: - ss << "pow("; break;; - case HYPOT: - ss << "hypot("; break;; - case CATH: - ss << "cath("; break;; - case ROUND: - ss << "round("; break;; - case TRUNC: - ss << "trunc("; break;; - case CEIL: - ss << "ceil("; break;; + case EXP: + ss << "exp("; break;; case FLOOR: ss << "floor("; break;; - case SUM: - ss << "sum("; break;; - case COUNT: - ss << "count("; break;; - case AVERAGE: - ss << "average("; break;; - case STDDEV: - ss << "stddev("; break;; - case MIN: - ss << "min("; break;; - case MAX: - ss << "max("; break;; - case LIST: - ss << "list("; break;; - case TUPLE: - ss << "tuple("; break;; - case MSCALE: - ss << "mscale("; break;; + case HYPOT: + ss << "hypot("; break;; + case LOG: + ss << "log("; break;; + case LOG10: + ss << "log10("; break;; + case MOD: + ss << "mod("; break;; + case POW: + ss << "pow("; break;; + case ROUND: + ss << "round("; break;; + case SIN: + ss << "sin("; break;; + case SINH: + ss << "sinh("; break;; + case SQRT: + ss << "sqrt("; break;; + case TAN: + ss << "tan("; break;; + case TANH: + ss << "tanh("; break;; + case TRUNC: + ss << "trunc("; break;; case MINVERT: ss << "minvert("; break;; + case MROTATE: + ss << "mrotate("; break;; + case MROTATEX: + ss << "mrotatex("; break;; + case MROTATEY: + ss << "mrotatey("; break;; + case MROTATEZ: + ss << "mrotatez("; break;; + case MSCALE: + ss << "mscale("; break;; + case MTRANSLATE: + ss << "mtranslate("; break;; case CREATE: ss << "create("; break;; + case LIST: + ss << "list("; break;; + case MATRIX: + ss << "matrix("; break;; + case PLACEMENT: + ss << "placement("; break;; + case ROTATION: + ss << "rotation("; break;; + case ROTATIONX: + ss << "rotationx("; break;; + case ROTATIONY: + ss << "rotationy("; break;; + case ROTATIONZ: + ss << "rotationz("; break;; case STR: ss << "str("; break;; + case TRANSLATIONM: + ss << "translationm("; break;; + case TUPLE: + ss << "tuple("; break;; + case VECTOR: + ss << "vector("; break;; case HIDDENREF: ss << "hiddenref("; break;; case HREF: ss << "href("; break;; + case AVERAGE: + ss << "average("; break;; + case COUNT: + ss << "count("; break;; + case MAX: + ss << "max("; break;; + case MIN: + ss << "min("; break;; + case STDDEV: + ss << "stddev("; break;; + case SUM: + ss << "sum("; break;; default: ss << fname << "("; break;; } @@ -3267,46 +3471,62 @@ static void initParser(const App::DocumentObject *owner) unitExpression = valueExpression = false; if (!has_registered_functions) { + registered_functions["abs"] = FunctionExpression::ABS; registered_functions["acos"] = FunctionExpression::ACOS; registered_functions["asin"] = FunctionExpression::ASIN; registered_functions["atan"] = FunctionExpression::ATAN; - registered_functions["abs"] = FunctionExpression::ABS; - registered_functions["exp"] = FunctionExpression::EXP; - registered_functions["log"] = FunctionExpression::LOG; - registered_functions["log10"] = FunctionExpression::LOG10; - registered_functions["sin"] = FunctionExpression::SIN; - registered_functions["sinh"] = FunctionExpression::SINH; - registered_functions["tan"] = FunctionExpression::TAN; - registered_functions["tanh"] = FunctionExpression::TANH; - registered_functions["sqrt"] = FunctionExpression::SQRT; + registered_functions["atan2"] = FunctionExpression::ATAN2; + registered_functions["cath"] = FunctionExpression::CATH; registered_functions["cbrt"] = FunctionExpression::CBRT; + registered_functions["ceil"] = FunctionExpression::CEIL; registered_functions["cos"] = FunctionExpression::COS; registered_functions["cosh"] = FunctionExpression::COSH; - registered_functions["atan2"] = FunctionExpression::ATAN2; + registered_functions["exp"] = FunctionExpression::EXP; + registered_functions["floor"] = FunctionExpression::FLOOR; + registered_functions["hypot"] = FunctionExpression::HYPOT; + registered_functions["log"] = FunctionExpression::LOG; + registered_functions["log10"] = FunctionExpression::LOG10; registered_functions["mod"] = FunctionExpression::MOD; registered_functions["pow"] = FunctionExpression::POW; registered_functions["round"] = FunctionExpression::ROUND; + registered_functions["sin"] = FunctionExpression::SIN; + registered_functions["sinh"] = FunctionExpression::SINH; + registered_functions["sqrt"] = FunctionExpression::SQRT; + registered_functions["tan"] = FunctionExpression::TAN; + registered_functions["tanh"] = FunctionExpression::TANH; registered_functions["trunc"] = FunctionExpression::TRUNC; - registered_functions["ceil"] = FunctionExpression::CEIL; - registered_functions["floor"] = FunctionExpression::FLOOR; - registered_functions["hypot"] = FunctionExpression::HYPOT; - registered_functions["cath"] = FunctionExpression::CATH; - registered_functions["list"] = FunctionExpression::LIST; - registered_functions["tuple"] = FunctionExpression::TUPLE; - registered_functions["mscale"] = FunctionExpression::MSCALE; + registered_functions["minvert"] = FunctionExpression::MINVERT; + registered_functions["mrotate"] = FunctionExpression::MROTATE; + registered_functions["mrotatex"] = FunctionExpression::MROTATEX; + registered_functions["mrotatey"] = FunctionExpression::MROTATEY; + registered_functions["mrotatez"] = FunctionExpression::MROTATEZ; + registered_functions["mscale"] = FunctionExpression::MSCALE; + registered_functions["mtranslate"] = FunctionExpression::MTRANSLATE; + registered_functions["create"] = FunctionExpression::CREATE; + registered_functions["list"] = FunctionExpression::LIST; + registered_functions["matrix"] = FunctionExpression::MATRIX; + registered_functions["placement"] = FunctionExpression::PLACEMENT; + registered_functions["rotation"] = FunctionExpression::ROTATION; + registered_functions["rotationx"] = FunctionExpression::ROTATIONX; + registered_functions["rotationy"] = FunctionExpression::ROTATIONY; + registered_functions["rotationz"] = FunctionExpression::ROTATIONZ; registered_functions["str"] = FunctionExpression::STR; + registered_functions["translationm"] = FunctionExpression::TRANSLATIONM; + registered_functions["tuple"] = FunctionExpression::TUPLE; + registered_functions["vector"] = FunctionExpression::VECTOR; + registered_functions["hiddenref"] = FunctionExpression::HIDDENREF; registered_functions["href"] = FunctionExpression::HREF; // Aggregates - registered_functions["sum"] = FunctionExpression::SUM; - registered_functions["count"] = FunctionExpression::COUNT; registered_functions["average"] = FunctionExpression::AVERAGE; - registered_functions["stddev"] = FunctionExpression::STDDEV; - registered_functions["min"] = FunctionExpression::MIN; + registered_functions["count"] = FunctionExpression::COUNT; registered_functions["max"] = FunctionExpression::MAX; + registered_functions["min"] = FunctionExpression::MIN; + registered_functions["stddev"] = FunctionExpression::STDDEV; + registered_functions["sum"] = FunctionExpression::SUM; has_registered_functions = true; } diff --git a/src/App/ExpressionParser.h b/src/App/ExpressionParser.h index f115ad2736..c985093435 100644 --- a/src/App/ExpressionParser.h +++ b/src/App/ExpressionParser.h @@ -26,7 +26,9 @@ #define EXPRESSION_PARSER_H #include "Expression.h" +#include #include +#include namespace App { @@ -244,48 +246,66 @@ public: NONE, // Normal functions taking one or two arguments + ABS, ACOS, ASIN, ATAN, - ABS, - EXP, - LOG, - LOG10, - SIN, - SINH, - TAN, - TANH, - SQRT, + ATAN2, + CATH, CBRT, + CEIL, COS, COSH, - ATAN2, + EXP, + FLOOR, + HYPOT, + LOG, + LOG10, MOD, POW, ROUND, + SIN, + SINH, + SQRT, + TAN, + TANH, TRUNC, - CEIL, - FLOOR, - HYPOT, - CATH, - LIST, - TUPLE, - MSCALE, // matrix scale by vector + + // Matrix MINVERT, // invert matrix/placement/rotation - CREATE, // create new object of a given type + MROTATE, // Rotate matrix/placement/rotation around axis, by rotation object, or by euler angles. + MROTATEX, // Rotate matrix/placement/rotation around x-axis. + MROTATEY, // Rotate matrix/placement/rotation around y-axis. + MROTATEZ, // Rotate matrix/placement/rotation around z-axis. + MSCALE, // matrix scale by vector + MTRANSLATE, // Translate matrix/placement. + + // Object creation + CREATE, // Create new object of a given type. + LIST, // Create Python list. + MATRIX, // Create matrix object. + PLACEMENT, // Create placement object. + ROTATION, // Create rotation object. + ROTATIONX, // Create x-axis rotation object. + ROTATIONY, // Create y-axis rotation object. + ROTATIONZ, // Create z-axis rotation object. STR, // stringify + TRANSLATIONM, // Create translation matrix object. + TUPLE, // Create Python tuple. + VECTOR, // Create vector object. + HIDDENREF, // hidden reference that has no dependency check HREF, // deprecated alias of HIDDENREF // Aggregates AGGREGATES, - SUM, AVERAGE, - STDDEV, COUNT, - MIN, MAX, + MIN, + STDDEV, + SUM, // Last one LAST, @@ -307,6 +327,13 @@ public: protected: static Py::Object evalAggregate(const Expression *owner, int type, const std::vector &args); + static Base::Vector3d evaluateSecondVectorArgument(const Expression *expression, const std::vector &arguments); + static void initialiseObject(const Py::Object *object, const std::vector &arguments, const unsigned long offset = 0); + static Py::Object transformFirstArgument( + const Expression *expression, + const std::vector &arguments, + const Base::Matrix4D *transformationMatrix); + static Py::Object translationMatrix(double x, double y, double z); Py::Object _getPyValue() const override; Expression * _copy() const override; void _visit(ExpressionVisitor & v) override; diff --git a/src/App/FreeCADInit.py b/src/App/FreeCADInit.py index e00275344a..9670c56db7 100644 --- a/src/App/FreeCADInit.py +++ b/src/App/FreeCADInit.py @@ -63,16 +63,6 @@ def setupSearchPaths(PathExtension): # new paths must be prepended to avoid to load a wrong version of a library try: os.environ["PATH"] = PathEnvironment + os.environ["PATH"] - except UnicodeDecodeError: - # See #0002238. FIXME: check again once ported to Python 3.x - Log('UnicodeDecodeError was raised when concatenating unicode string with PATH. Try to remove non-ascii paths...\n') - path = os.environ["PATH"].split(os.pathsep) - cleanpath=[] - for i in path: - if test_ascii(i): - cleanpath.append(i) - os.environ["PATH"] = PathEnvironment + os.pathsep.join(cleanpath) - Log('done\n') except UnicodeEncodeError: Log('UnicodeEncodeError was raised when concatenating unicode string with PATH. Try to replace non-ascii chars...\n') os.environ["PATH"] = PathEnvironment.encode(errors='replace') + os.environ["PATH"] @@ -305,7 +295,6 @@ Wrn = FreeCAD.Console.PrintWarning Crt = FreeCAD.Console.PrintCritical Ntf = FreeCAD.Console.PrintNotification Tnf = FreeCAD.Console.PrintTranslatedNotification -test_ascii = lambda s: all(ord(c) < 128 for c in s) #store the cmake variales App.__cmake__ = cmake; @@ -962,6 +951,5 @@ App.ReturnType = ReturnType # clean up namespace del(InitApplications) -del(test_ascii) Log ('Init: App::FreeCADInit.py done\n') diff --git a/src/App/IndexedName.cpp b/src/App/IndexedName.cpp new file mode 100644 index 0000000000..ebe9bf2ddb --- /dev/null +++ b/src/App/IndexedName.cpp @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later + +/**************************************************************************** + * Copyright (c) 2022 Zheng, Lei (realthunder) * + * Copyright (c) 2023 FreeCAD Project Association * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +// NOLINTNEXTLINE +#include "PreCompiled.h" + +#ifndef _PreComp_ +# include +# include +#endif + +#include "IndexedName.h" + +using namespace Data; + +/// Check whether the input character is an underscore or an ASCII letter a-Z or A-Z +inline bool isInvalidChar(char test) +{ + return test != '_' && (test < 'a' || test > 'z' ) && (test < 'A' || test > 'Z'); +} + +/// Get the integer suffix of name. Returns a tuple of (suffix, suffixPosition). Calling code +/// should check to ensure that suffixPosition is not equal to nameLength (in which case there was no +/// suffix). +/// +/// \param name The name to check +/// \param nameLength The length of the string in name +/// \returns An integer pair of the suffix itself and the position of that suffix in name +std::pair getIntegerSuffix(const char *name, int nameLength) +{ + int suffixPosition {nameLength - 1}; + + for (; suffixPosition >= 0; --suffixPosition) { + // When we support C++20 we can use std::span<> to eliminate the clang-tidy warning + // NOLINTNEXTLINE cppcoreguidelines-pro-bounds-pointer-arithmetic + if (!isdigit(name[suffixPosition])) { + break; + } + } + ++suffixPosition; + int suffix {0}; + if (suffixPosition < nameLength) { + // When we support C++20 we can use std::span<> to eliminate the clang-tidy warning + // NOLINTNEXTLINE cppcoreguidelines-pro-bounds-pointer-arithmetic + suffix = std::atoi(name + suffixPosition); + } + return std::make_pair(suffix, suffixPosition); +} + +void IndexedName::set( + const char* name, + int length, + const std::vector& allowedNames, + bool allowOthers) +{ + // Storage for names that we weren't given external storage for + static std::unordered_set NameSet; + + if (length < 0) { + length = static_cast(std::strlen(name)); + } + // Name typically ends with an integer: find that integer + auto [suffix, suffixPosition] = getIntegerSuffix(name, length); + if (suffixPosition < length) { + this->index = suffix; + } + + // Make sure that every character is either an ASCII letter (upper or lowercase), or an + // underscore. If any other character appears, reject the entire string. + // When we support C++20 we can use std::span<> to eliminate the clang-tidy warning + // NOLINTNEXTLINE cppcoreguidelines-pro-bounds-pointer-arithmetic + if (std::any_of(name, name+suffixPosition, isInvalidChar)) { + this->type = ""; + return; + } + + // If a list of allowedNames was provided, see if our set name matches one of those allowedNames: if it + // does, reference that memory location and return. + for (const auto *typeName : allowedNames) { + if (std::strncmp(name, typeName, suffixPosition) == 0) { + this->type = typeName; + return; + } + } + + // If the type was NOT in the list of allowedNames, but the caller has set the allowOthers flag to + // true, then add the new type to the static NameSet (if it is not already there). + if (allowOthers) { + auto res = NameSet.insert(ByteArray(QByteArray::fromRawData(name, suffixPosition))); + if (res.second /*The insert succeeded (the type was new)*/) { + // Make sure that the data in the set is a unique (unshared) copy of the text + res.first->ensureUnshared(); + } + this->type = res.first->bytes.constData(); + } + else { + // The passed-in type is not in the allowed list, and allowOthers was not true, so don't + // store the type + this->type = ""; + } +} diff --git a/src/App/IndexedName.h b/src/App/IndexedName.h new file mode 100644 index 0000000000..85282b9545 --- /dev/null +++ b/src/App/IndexedName.h @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later + +/**************************************************************************** + * Copyright (c) 2022 Zheng, Lei (realthunder) * + * Copyright (c) 2023 FreeCAD Project Association * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD 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 * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#ifndef APP_INDEXEDNAME_H +#define APP_INDEXEDNAME_H + +#include +#include +#include +#include +#include + +#include +#include + +#include "FCGlobal.h" + + +namespace Data +{ + +/// The IndexedName class provides a very memory-efficient data structure to hold a name and an index +/// value, and to perform various comparisons and validations of those values. The name must only +/// consist of upper- and lower-case ASCII characters and the underscore ('_') character. The index +/// must be a positive integer. The string representation of this IndexedName is the name followed by +/// the index, with no spaces between: an IndexedName may be constructed from this string. For +/// example "EDGE1" or "FACE345" might be the names of elements that use an IndexedName. If there is +/// then an "EDGE2", only a pointer to the original stored name "EDGE" is retained. +/// +/// The memory efficiency of the class comes from re-using the same character storage for names that +/// match, while retaining their differing indices. This is achieved by either using user-provided +/// const char * names (provided as a list of typeNames and presumed to never be deallocated), or by +/// maintaining an internal list of names that have been used before, and can be re-used later. +class AppExport IndexedName { +public: + + /// Construct from a name and an optional index. If the name contains an index it is read, but + /// is used as the index *only* if _index parameter is unset. If the _index parameter is given + /// it overrides any trailing integer in the name. Index must be positive, and name must contain + /// only ASCII letters and the underscore character. If these conditions are not met, name is + /// set to the empty string, and isNull() will return true. + /// + /// \param name The new name - ASCII letters and underscores only, with optional integer suffix. + /// This memory will be copied into a new internal storage location and need not be persistent. + /// \param _index The new index - if provided, it overrides any suffix provided by name + explicit IndexedName(const char *name = nullptr, int _index = 0) + : index(0) + { + assert(_index >= 0); + if (!name) { + this->type = ""; + } + else { + set(name); + if (_index > 0) { + this->index = _index; + } + } + } + + /// Create an indexed name that is restricted to a list of preset type names. If it appears in + /// that list, only a pointer to the character storage in the list is retained: the memory + /// locations pointed at by the list must never be destroyed once they have been used to create + /// names. If allowOthers is true (the default) then a requested name that is not in the list + /// will be added to a static internal storage table, and its memory then re-used for later + /// objects with the same name. If allowOthers is false, then the name request is rejected, and + /// the name is treated as null. + /// + /// \param name The new name - ASCII letters and underscores only, with optional integer suffix + /// \param allowedTypeNames A vector of allowed names. Storage locations must persist for the + /// entire run of the program. + /// \param allowOthers Whether a name not in allowedTypeNames is permitted. If true (the + /// default) then a name not in allowedTypeNames is added to a static internal storage vector + /// so that it can be re-used later without additional memory allocation. + IndexedName(const char *name, + const std::vector & allowedTypeNames, + bool allowOthers=true) : type(""), index(0) + { + set(name, -1, allowedTypeNames, allowOthers); + } + + /// Construct from a QByteArray, but explicitly making a copy of the name on its first + /// occurrence. If this is a name that has already been stored internally, no additional copy + /// is made. + /// + /// \param data The QByteArray to copy the data from + explicit IndexedName(const QByteArray & data) : type(""), index(0) + { + set(data.constData(), data.size()); + } + + /// Given constant name and an index, re-use the existing memory for the name, not making a copy + /// of it, or scanning any existing storage for it. The name must never become invalid for the + /// lifetime of the object it names. This memory will never be re-used by another object. + /// + /// \param name The name of the object. This memory is NOT copied and must be persistent. + /// \param index A positive, non-zero integer + /// \return An IndexedName with the given name and index, re-using the existing memory for name + static IndexedName fromConst(const char *name, int index) { + assert (index >= 0); + IndexedName res; + res.type = name; + res.index = index; + return res; + } + + /// Given an existing std::string, *append* this name to it. If index is not zero, this will + /// include the index. + /// + /// \param buffer A (possibly non-empty) string buffer to append the name to. + void appendToStringBuffer(std::string & buffer) const + { + buffer += this->type; + if (this->index > 0) { + buffer += std::to_string(this->index); + } + } + + /// Create and return a new std::string with this name in it. + /// + /// \return A newly-created string with the IndexedName in it (e.g. "EDGE42") + std::string toString() const + { + std::string result; + this->appendToStringBuffer(result); + return result; + } + + /// An indexedName is represented as the simple concatenation of the name and its index, e.g. + /// "EDGE1" or "FACE42". + friend std::ostream & operator<<(std::ostream & stream, const IndexedName & indexedName) + { + stream << indexedName.type; + if (indexedName.index > 0) { + stream << indexedName.index; + } + return stream; + } + + /// True only if both the name and index compare exactly equal. + bool operator==(const IndexedName & other) const + { + return this->index == other.index + && (this->type == other.type + || std::strcmp(this->type, other.type)==0); + } + + /// Increments the index by the given offset. Does not affect the text part of the name. + IndexedName & operator+=(int offset) + { + this->index += offset; + assert(this->index >= 0); + return *this; + } + + /// Pre-increment operator: increases the index of this element by one. + IndexedName & operator++() + { + ++this->index; + return *this; + } + + /// Pre-decrement operator: decreases the index of this element by one. Must not make the index + /// negative (only checked when compiled in debug mode). + IndexedName & operator--() + { + --this->index; + assert(this->index >= 0); + return *this; + } + + /// True if either the name or the index compare not equal. + bool operator!=(const IndexedName & other) const + { + return !(this->operator==(other)); + } + + /// Equivalent to C++20's operator <=> + int compare(const IndexedName & other) const + { + int res = std::strcmp(this->type, other.type); + if (res != 0) { + return res; + } + if (this->index < other.index) { + return -1; + } + if (this->index > other.index) { + return 1; + } + return 0; + } + + /// Provided to enable sorting operations: the comparison is first lexicographical for the text + /// element of the names, then numerical for the indices. + bool operator<(const IndexedName & other) const + { + return compare(other) < 0; + } + + /// Allow direct memory access to the individual characters of the text portion of the name. + /// NOTE: input is not range-checked when compiled in release mode. + char operator[](int input) const + { + assert(input >= 0); + assert(input < static_cast(std::strlen(this->type))); + // When we support C++20 we can use std::span<> to eliminate the clang-tidy warning + // NOLINTNEXTLINE cppcoreguidelines-pro-bounds-pointer-arithmetic + return this->type[input]; + } + + /// Get a pointer to text part of the name - does NOT make a copy, returns direct memory access + const char * getType() const { return this->type; } + + /// Get the numerical part of the name + int getIndex() const { return this->index; } + + /// Set the numerical part of the name (note that there is no equivalent function to allow + /// changing the text part of the name, which is immutable once created). + /// + /// \param input The new index. Must be a positive non-zero integer + void setIndex(int input) { assert(input>=0); this->index = input; } + + /// A name is considered "null" if its text component is an empty string. + // When we support C++20 we can use std::span<> to eliminate the clang-tidy warning + // NOLINTNEXTLINE cppcoreguidelines-pro-bounds-pointer-arithmetic + bool isNull() const { return this->type[0] == '\0'; } + + /// Boolean conversion provides the opposite of isNull(), yielding true when the text part of + /// the name is NOT the empty string. + explicit operator bool() const { return !isNull(); } + +protected: + /// Apply the IndexedName rules and either store the characters of a new type or a reference to + /// the characters in a type named in types, or stored statically within this function. If len + /// is not set, or set to -1 (the default), then the provided string in name is scanned for its + /// length using strlen (e.g. it must be null-terminated). + /// + /// \param name The new name. If necessary a copy is made, this char * need not be persistent + /// \param length The length of name + /// \param allowedNames A vector of storage locations of allowed names. These storage locations + /// must be persistent for the duration of the program run. + /// \param allowOthers If true (the default), then if name is not in allowedNames it is allowed, + /// and it is added to internal storage (making a copy of the name if this is its first + /// occurrence). + void set(const char *name, + int length = -1, + const std::vector & allowedNames = {}, + bool allowOthers = true); + +private: + const char * type; + int index; +}; + + +/// A thin wrapper around a QByteArray providing the ability to force a copy of the data at any +/// time, even if it isn't being written to. The standard assignment operator for this class *does* +/// make a copy of the data, unlike the standard assignment operator for QByteArray. +struct ByteArray +{ + explicit ByteArray(QByteArray other) + :bytes(std::move(other)) + {} + + ByteArray(const ByteArray& other) = default; + + ByteArray(ByteArray&& other) noexcept + :bytes(std::move(other.bytes)) + {} + + ~ByteArray() = default; + + /// Guarantee that the stored QByteArray does not share its memory with another instance. + void ensureUnshared() const + { + QByteArray copy; + copy.append(bytes.constData(), bytes.size()); + bytes = copy; + } + + bool operator==(const ByteArray& other) const { + return bytes == other.bytes; + } + + ByteArray &operator=(const ByteArray & other) { + bytes.clear(); + bytes.append(other.bytes.constData(), other.bytes.size()); + return *this; + } + + ByteArray &operator= (ByteArray&& other) noexcept + { + bytes = std::move(other.bytes); + return *this; + } + + mutable QByteArray bytes; +}; + + +struct ByteArrayHasher +{ + std::size_t operator()(const ByteArray& bytes) const + { + return qHash(bytes.bytes); + } + + std::size_t operator()(const QByteArray& bytes) const + { + return qHash(bytes); + } +}; + +} + +#endif // APP_INDEXEDNAME_H diff --git a/src/Base/CMakeLists.txt b/src/Base/CMakeLists.txt index 38609caf05..a82dd77b2d 100644 --- a/src/Base/CMakeLists.txt +++ b/src/Base/CMakeLists.txt @@ -231,6 +231,7 @@ SET(FreeCADBase_CPP_SRCS MatrixPyImp.cpp MemDebug.cpp Mutex.cpp + Observer.cpp Parameter.xsd Parameter.cpp ParameterPy.cpp diff --git a/src/Base/Factory.h b/src/Base/Factory.h index 035a78b173..76d4e7a792 100644 --- a/src/Base/Factory.h +++ b/src/Base/Factory.h @@ -49,8 +49,8 @@ public: /** Base class of all factories - * This class has the purpose to produce at runtime instances - * of classes not known at compile time. It holds a map of so called + * This class has the purpose to produce instances of classes at runtime + * that are unknown at compile time. It holds a map of so called * producers which are able to produce an instance of a special class. * Producer can be registered at runtime through e.g. application modules */ diff --git a/src/Base/Observer.cpp b/src/Base/Observer.cpp new file mode 100644 index 0000000000..26813b28ff --- /dev/null +++ b/src/Base/Observer.cpp @@ -0,0 +1,34 @@ +/*************************************************************************** + * Copyright (c) 2023 Abdullah Tahiri * + * * + * This file is part of the FreeCAD CAx development system. * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU Library General Public License (LGPL) * + * as published by the Free Software Foundation; either version 2 of * + * the License, or (at your option) any later version. * + * for detail see the LICENCE text file. * + * * + * FreeCAD 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 Library General Public License for more details. * + * * + * You should have received a copy of the GNU Library General Public * + * License along with FreeCAD; if not, write to the Free Software * + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * + * USA * + * * + ***************************************************************************/ + +#include "PreCompiled.h" + +#include "Observer.h" + + +namespace Base { + +template class BaseExport Observer; +template class BaseExport Subject; + +} //namespace Base diff --git a/src/Base/Observer.h b/src/Base/Observer.h index 00de45e81e..494c1d23cc 100644 --- a/src/Base/Observer.h +++ b/src/Base/Observer.h @@ -216,16 +216,17 @@ protected: std::set *> _ObserverSet; }; -#if defined (FC_OS_WIN32) || defined(FC_OS_CYGWIN) -# ifdef FCBase -template class BaseExport Observer; -template class BaseExport Subject; -# else -extern template class BaseExport Observer; -extern template class BaseExport Subject; -# endif +// Workaround for MSVC +#if defined (FreeCADBase_EXPORTS) && defined(_MSC_VER) +# define Base_EXPORT +#else +# define Base_EXPORT BaseExport #endif +extern template class Base_EXPORT Observer; +extern template class Base_EXPORT Subject; + + } //namespace Base diff --git a/src/Base/ProgressIndicatorPy.cpp b/src/Base/ProgressIndicatorPy.cpp index a3a4a47405..a5c0dfcfe2 100644 --- a/src/Base/ProgressIndicatorPy.cpp +++ b/src/Base/ProgressIndicatorPy.cpp @@ -43,6 +43,21 @@ void ProgressIndicatorPy::init_type() add_varargs_method("stop",&ProgressIndicatorPy::stop,"stop()"); } +Py::PythonType& ProgressIndicatorPy::behaviors() +{ + return Py::PythonExtension::behaviors(); +} + +PyTypeObject* ProgressIndicatorPy::type_object() +{ + return Py::PythonExtension::type_object(); +} + +bool ProgressIndicatorPy::check(PyObject* p) +{ + return Py::PythonExtension::check(p); +} + PyObject *ProgressIndicatorPy::PyMake(struct _typeobject *, PyObject *, PyObject *) { return new ProgressIndicatorPy(); diff --git a/src/Base/ProgressIndicatorPy.h b/src/Base/ProgressIndicatorPy.h index 556fc97bdc..249068b02f 100644 --- a/src/Base/ProgressIndicatorPy.h +++ b/src/Base/ProgressIndicatorPy.h @@ -35,6 +35,9 @@ class BaseExport ProgressIndicatorPy : public Py::PythonExtension - - - - QDialogButtonBox::Close - - - @@ -531,21 +524,5 @@ - - buttonBox - rejected() - Gui::Dialog::DlgDisplayProperties - reject() - - - 150 - 461 - - - 144 - 243 - - - diff --git a/src/Gui/DlgDisplayPropertiesImp.cpp b/src/Gui/DlgDisplayPropertiesImp.cpp index a6d9f87e49..14f8874169 100644 --- a/src/Gui/DlgDisplayPropertiesImp.cpp +++ b/src/Gui/DlgDisplayPropertiesImp.cpp @@ -192,11 +192,6 @@ void DlgDisplayPropertiesImp::setupConnections() connect(d->ui.buttonColorPlot, &ColorButton::clicked, this, &DlgDisplayPropertiesImp::onButtonColorPlotClicked); } -void DlgDisplayPropertiesImp::showDefaultButtons(bool ok) -{ - d->ui.buttonBox->setVisible(ok); -} - void DlgDisplayPropertiesImp::changeEvent(QEvent *e) { if (e->type() == QEvent::LanguageChange) { @@ -630,7 +625,6 @@ TaskDisplayProperties::TaskDisplayProperties() { this->setButtonPosition(TaskDisplayProperties::North); widget = new DlgDisplayPropertiesImp(false); - widget->showDefaultButtons(false); taskbox = new Gui::TaskView::TaskBox(QPixmap(), widget->windowTitle(),true, nullptr); taskbox->groupLayout()->addWidget(widget); Content.push_back(taskbox); diff --git a/src/Gui/DlgPreferencePackManagementImp.cpp b/src/Gui/DlgPreferencePackManagementImp.cpp index 54365de62a..b956965e79 100644 --- a/src/Gui/DlgPreferencePackManagementImp.cpp +++ b/src/Gui/DlgPreferencePackManagementImp.cpp @@ -70,7 +70,7 @@ void DlgPreferencePackManagementImp::showEvent(QShowEvent* event) for (const auto& mod : fs::directory_iterator(modDirectory)) { auto packs = getPacksFromDirectory(mod); if (!packs.empty()) { - auto modName = mod.path().leaf().string(); + auto modName = mod.path().filename().string(); installedPacks.emplace(modName, packs); } } diff --git a/src/Gui/DlgSettingsNotificationArea.cpp b/src/Gui/DlgSettingsNotificationArea.cpp new file mode 100644 index 0000000000..cc1daec243 --- /dev/null +++ b/src/Gui/DlgSettingsNotificationArea.cpp @@ -0,0 +1,102 @@ +/*************************************************************************** + * Copyright (c) 2023 Abdullah Tahiri * + * * + * This file is part of the FreeCAD CAx development system. * + * * + * This library is free software; you can redistribute it and/or * + * modify it under the terms of the GNU Library General Public * + * License as published by the Free Software Foundation; either * + * version 2 of the License, or (at your option) any later version. * + * * + * This library 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 Library General Public License for more details. * + * * + * You should have received a copy of the GNU Library General Public * + * License along with this library; see the file COPYING.LIB. If not, * + * write to the Free Software Foundation, Inc., 59 Temple Place, * + * Suite 330, Boston, MA 02111-1307, USA * + * * + ***************************************************************************/ + +#include "PreCompiled.h" + +#ifndef _PreComp_ +# include +#endif + +#include "DlgSettingsNotificationArea.h" +#include "ui_DlgSettingsNotificationArea.h" + + +using namespace Gui::Dialog; + +/* TRANSLATOR Gui::Dialog::DlgSettingsNotificationArea */ + +DlgSettingsNotificationArea::DlgSettingsNotificationArea(QWidget* parent) + : PreferencePage(parent) + , ui(new Ui_DlgSettingsNotificationArea) +{ + ui->setupUi(this); + + connect(ui->NotificationAreaEnabled, &QCheckBox::stateChanged, [this](int state) { + if(state == Qt::CheckState::Checked) { + ui->NonIntrusiveNotificationsEnabled->setEnabled(true); + ui->maxDuration->setEnabled(true); + ui->maxDuration->setEnabled(true); + ui->minDuration->setEnabled(true); + ui->maxNotifications->setEnabled(true); + ui->maxWidgetMessages->setEnabled(true); + ui->autoRemoveUserNotifications->setEnabled(true); + QMessageBox::information(this, tr("Notification Area"), + tr("Activation of the Notification Area only takes effect after an application restart.")); + } + else { + ui->NonIntrusiveNotificationsEnabled->setEnabled(false); + ui->maxDuration->setEnabled(false); + ui->maxDuration->setEnabled(false); + ui->minDuration->setEnabled(false); + ui->maxNotifications->setEnabled(false); + ui->maxWidgetMessages->setEnabled(false); + ui->autoRemoveUserNotifications->setEnabled(false); + // N.B: Deactivation is handled by the Notification Area itself, as it listens to all its configuration parameters. + } + }); +} + +DlgSettingsNotificationArea::~DlgSettingsNotificationArea() +{ +} + +void DlgSettingsNotificationArea::saveSettings() +{ + ui->NotificationAreaEnabled->onSave(); + ui->NonIntrusiveNotificationsEnabled->onSave(); + ui->maxDuration->onSave(); + ui->minDuration->onSave(); + ui->maxNotifications->onSave(); + ui->maxWidgetMessages->onSave(); + ui->autoRemoveUserNotifications->onSave(); +} + +void DlgSettingsNotificationArea::loadSettings() +{ + ui->NotificationAreaEnabled->onRestore(); + ui->NonIntrusiveNotificationsEnabled->onRestore(); + ui->maxDuration->onRestore(); + ui->minDuration->onRestore(); + ui->maxNotifications->onRestore(); + ui->maxWidgetMessages->onRestore(); + ui->autoRemoveUserNotifications->onRestore(); +} + +void DlgSettingsNotificationArea::changeEvent(QEvent *e) +{ + if (e->type() == QEvent::LanguageChange) { + ui->retranslateUi(this); + } + QWidget::changeEvent(e); +} + +#include "moc_DlgSettingsNotificationArea.cpp" diff --git a/src/Gui/DlgSettingsNotificationArea.h b/src/Gui/DlgSettingsNotificationArea.h new file mode 100644 index 0000000000..4aac834a3b --- /dev/null +++ b/src/Gui/DlgSettingsNotificationArea.h @@ -0,0 +1,59 @@ +/*************************************************************************** + * Copyright (c) 2023 Abdullah Tahiri * + * * + * This file is part of the FreeCAD CAx development system. * + * * + * This library is free software; you can redistribute it and/or * + * modify it under the terms of the GNU Library General Public * + * License as published by the Free Software Foundation; either * + * version 2 of the License, or (at your option) any later version. * + * * + * This library 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 Library General Public License for more details. * + * * + * You should have received a copy of the GNU Library General Public * + * License along with this library; see the file COPYING.LIB. If not, * + * write to the Free Software Foundation, Inc., 59 Temple Place, * + * Suite 330, Boston, MA 02111-1307, USA * + * * + ***************************************************************************/ + + +#ifndef GUI_DIALOG_DLGSETTINGSNOTIFICATIONAREA_H +#define GUI_DIALOG_DLGSETTINGSNOTIFICATIONAREA_H + +#include "PropertyPage.h" +#include + +namespace Gui { +namespace Dialog { +class Ui_DlgSettingsNotificationArea; + +/** + * The DlgSettingsNotificationArea class implements a preference page to change settings + * for the Notification Area. + */ +class DlgSettingsNotificationArea : public PreferencePage +{ + Q_OBJECT + +public: + explicit DlgSettingsNotificationArea(QWidget* parent = nullptr); + ~DlgSettingsNotificationArea() override; + + void saveSettings() override; + void loadSettings() override; + +protected: + void changeEvent(QEvent *e) override; + +private: + std::unique_ptr ui; +}; + +} // namespace Dialog +} // namespace Gui + +#endif // GUI_DIALOG_DLGSETTINGSNOTIFICATIONAREA_H diff --git a/src/Gui/DlgSettingsNotificationArea.ui b/src/Gui/DlgSettingsNotificationArea.ui new file mode 100644 index 0000000000..d430c5ee94 --- /dev/null +++ b/src/Gui/DlgSettingsNotificationArea.ui @@ -0,0 +1,240 @@ + + + Gui::Dialog::DlgSettingsNotificationArea + + + + 0 + 0 + 654 + 356 + + + + Notification Area + + + + + + Non-Intrusive Notifications + + + + + + Minimum Duration: + + + + + + + + + + Maximum Duration: + + + + + + + Duration during which the notification will be shown (unless mouse buttons are clicked) + + + s + + + 0 + + + 120 + + + 20 + + + NotificationTime + + + NotificationArea + + + + + + + Minimum duration during which the notification will be shown (unless notification clicked) + + + s + + + 5 + + + MinimumOnScreenTime + + + NotificationArea + + + + + + + Maximum Number of Notifications: + + + + + + + Maximum number of notifications that will be simultaneously present on the screen + + + 15 + + + MaxOpenNotifications + + + NotificationArea + + + + + + + + + + Settings + + + + + + The Notification area will appear in the status bar + + + Enable Notification Area + + + true + + + NotificationAreaEnabled + + + NotificationArea + + + + + + + Non-intrusive notifications will appear next to the notification area in the status bar + + + Enable non-intrusive notifications + + + true + + + NonIntrusiveNotificationsEnabled + + + NotificationArea + + + + + + + + + + Qt::Vertical + + + + 20 + 63 + + + + + + + + Message List + + + + + + Limit the number of messages that will be kept in the list. If 0 there is no limit. + + + 10000 + + + 1000 + + + MaxWidgetMessages + + + NotificationArea + + + + + + + Maximum Messages (0 = no limit): + + + + + + + Removes the user notifications from the message list after the non-intrusive maximum duration has lapsed. + + + Auto-remove User Notifications + + + true + + + AutoRemoveUserNotifications + + + NotificationArea + + + + + + + + + + + Gui::PrefCheckBox + QCheckBox +
Gui/PrefWidgets.h
+
+ + Gui::PrefSpinBox + QSpinBox +
Gui/PrefWidgets.h
+
+
+ + +
diff --git a/src/Gui/Document.cpp b/src/Gui/Document.cpp index 2a4f9e8d22..5ff1c4c583 100644 --- a/src/Gui/Document.cpp +++ b/src/Gui/Document.cpp @@ -30,6 +30,7 @@ # include # include # include +# include # include # include #endif @@ -55,6 +56,7 @@ #include "FileDialog.h" #include "MainWindow.h" #include "MDIView.h" +#include "NotificationArea.h" #include "Selection.h" #include "Thumbnail.h" #include "Tree.h" @@ -72,175 +74,6 @@ namespace bp = boost::placeholders; namespace Gui { -/** This class is an implementation only class to handle user notifications offered by App::Document. - * - * It provides a mechanism requiring confirmation for critical notifications only during User initiated restore/document loading ( it - * does not require confirmation for macro/Python initiated restore, not to interfere with automations). - * - * Additionally, it provides a mechanism to show autoclosing non-modal user notifications in a non-intrusive way. - **/ -class MessageManager { -public: - MessageManager() = default; - ~MessageManager(); - - void setDocument(Gui::Document * pDocument); - void slotUserMessage(const App::DocumentObject&, const QString &, App::Document::NotificationType); - - -private: - void reorderAutoClosingMessages(); - QMessageBox* createNonModalMessage(const QString & msg, App::Document::NotificationType notificationtype); - void pushAutoClosingMessage(const QString & msg, App::Document::NotificationType notificationtype); - void pushAutoClosingMessageTooManyMessages(); - -private: - using Connection = boost::signals2::connection; - Gui::Document * pDoc; - Connection connectUserMessage; - bool requireConfirmationCriticalMessageDuringRestoring = true; - std::vector openAutoClosingMessages; - std::mutex mutexAutoClosingMessages; - const int autoClosingTimeout = 5000; // ms - const int autoClosingMessageStackingOffset = 10; - const unsigned int maxNumberOfOpenAutoClosingMessages = 3; - bool maxNumberOfOpenAutoClosingMessagesLimitReached = false; -}; - -MessageManager::~MessageManager(){ - connectUserMessage.disconnect(); -} - -void MessageManager::setDocument(Gui::Document * pDocument) -{ - - pDoc = pDocument; - - connectUserMessage = pDoc->getDocument()->signalUserMessage.connect - (boost::bind(&Gui::MessageManager::slotUserMessage, this, bp::_1, bp::_2, bp::_3)); - -} - -void MessageManager::slotUserMessage(const App::DocumentObject& obj, const QString & msg, App::Document::NotificationType notificationtype) -{ - (void) obj; - - auto userInitiatedRestore = Application::Instance->testStatus(Gui::Application::UserInitiatedOpenDocument); - - if(notificationtype == App::Document::NotificationType::Critical && userInitiatedRestore && requireConfirmationCriticalMessageDuringRestoring) { - auto confirmMsg = msg + QStringLiteral("\n\n") + QObject::tr("Do you want to skip confirmation of further critical message notifications while loading the file?"); - auto button = QMessageBox::critical(pDoc->getActiveView(), QObject::tr("Critical Message"), confirmMsg, QMessageBox::Yes | QMessageBox::No, QMessageBox::No ); - - if(button == QMessageBox::Yes) - requireConfirmationCriticalMessageDuringRestoring = false; - } - else { // Non-critical errors and warnings - auto-closing non-blocking message box - - auto messageNumber = openAutoClosingMessages.size(); - - // Not opening more than the number of maximum autoclosing messages - // If maximum reached, the mechanism only resets after all present messages are auto-closed - if( messageNumber < maxNumberOfOpenAutoClosingMessages) { - if(messageNumber == 0 && maxNumberOfOpenAutoClosingMessagesLimitReached) { - maxNumberOfOpenAutoClosingMessagesLimitReached = false; - } - - if(!maxNumberOfOpenAutoClosingMessagesLimitReached) { - pushAutoClosingMessage(msg, notificationtype); - } - } - else { - if(!maxNumberOfOpenAutoClosingMessagesLimitReached) - pushAutoClosingMessageTooManyMessages(); - - maxNumberOfOpenAutoClosingMessagesLimitReached = true; - } - } -} - -void MessageManager::pushAutoClosingMessage(const QString & msg, App::Document::NotificationType notificationtype) -{ - std::lock_guard g(mutexAutoClosingMessages); // guard to avoid creating new messages while closing old messages (via timer) - - auto msgBox = createNonModalMessage(msg, notificationtype); - - msgBox->show(); - - int numberOpenAutoClosingMessages = openAutoClosingMessages.size(); - - openAutoClosingMessages.push_back(msgBox); - - reorderAutoClosingMessages(); - - QTimer::singleShot(autoClosingTimeout*numberOpenAutoClosingMessages, [msgBox, this](){ - std::lock_guard g(mutexAutoClosingMessages); // guard to avoid closing old messages while creating new ones - if(msgBox) { - msgBox->done(0); - openAutoClosingMessages.erase( - std::remove(openAutoClosingMessages.begin(), openAutoClosingMessages.end(), msgBox), - openAutoClosingMessages.end()); - - reorderAutoClosingMessages(); - } - }); -} - -void MessageManager::pushAutoClosingMessageTooManyMessages() -{ - pushAutoClosingMessage(QObject::tr("Too many message notifications. Notification temporarily stopped. Look at the report view for more information."), App::Document::NotificationType::Warning); -} - - -QMessageBox* MessageManager::createNonModalMessage(const QString & msg, App::Document::NotificationType notificationtype) -{ - auto parent = pDoc->getActiveView(); - - QMessageBox* msgBox = new QMessageBox(parent); - msgBox->setAttribute(Qt::WA_DeleteOnClose); // msgbox deleted automatically upon closed - msgBox->setStandardButtons(QMessageBox::NoButton); - msgBox->setWindowFlag(Qt::FramelessWindowHint,true); - msgBox->setText(msg); - - if(notificationtype == App::Document::NotificationType::Error) { - msgBox->setWindowTitle(QObject::tr("Error")); - msgBox->setIcon(QMessageBox::Critical); - } - else if(notificationtype == App::Document::NotificationType::Warning) { - msgBox->setWindowTitle(QObject::tr("Warning")); - msgBox->setIcon(QMessageBox::Warning); - } - else if(notificationtype == App::Document::NotificationType::Information) { - msgBox->setWindowTitle(QObject::tr("Information")); - msgBox->setIcon(QMessageBox::Information); - } - else if(notificationtype == App::Document::NotificationType::Critical) { - msgBox->setWindowTitle(QObject::tr("Critical")); - msgBox->setIcon(QMessageBox::Critical); - } - - msgBox->setModal( false ); // if you want it non-modal - - return msgBox; -} - -void MessageManager::reorderAutoClosingMessages() -{ - auto parent = pDoc->getActiveView(); - - int numberOpenAutoClosingMessages = openAutoClosingMessages.size(); - - auto x = parent->width() / 2; - auto y = parent->height() / 7; - - int posindex = numberOpenAutoClosingMessages - 1; - for (auto rit = openAutoClosingMessages.rbegin(); rit != openAutoClosingMessages.rend(); ++rit, posindex--) { - int xw = x - (*rit)->width() / 2 + autoClosingMessageStackingOffset*posindex;; - int yw = y + autoClosingMessageStackingOffset*posindex; - (*rit)->move(xw, yw); - (*rit)->raise(); - } -} - // Pimpl class struct DocumentP { @@ -301,8 +134,6 @@ struct DocumentP using ConnectionBlock = boost::signals2::shared_connection_block; ConnectionBlock connectActObjectBlocker; ConnectionBlock connectChangeDocumentBlocker; - - MessageManager messageManager; }; } // namespace Gui @@ -384,7 +215,6 @@ Document::Document(App::Document* pcDocument,Application * app) d->connectTransactionRemove = pcDocument->signalTransactionRemove.connect (boost::bind(&Gui::Document::slotTransactionRemove, this, bp::_1, bp::_2)); - d->messageManager.setDocument(this); // pointer to the python class // NOTE: As this Python object doesn't get returned to the interpreter we // mustn't increment it (Werner Jan-12-2006) diff --git a/src/Gui/GuiConsole.cpp b/src/Gui/GuiConsole.cpp index 3cb10b2882..764343979c 100644 --- a/src/Gui/GuiConsole.cpp +++ b/src/Gui/GuiConsole.cpp @@ -83,6 +83,8 @@ GUIConsole::~GUIConsole (void) void GUIConsole::SendLog(const std::string& notifiername, const std::string& msg, Base::LogStyle level) { + (void) notifiername; + int color = -1; switch(level){ case Base::LogStyle::Warning: @@ -100,6 +102,8 @@ void GUIConsole::SendLog(const std::string& notifiername, const std::string& msg case Base::LogStyle::Critical: color = FOREGROUND_RED | FOREGROUND_GREEN; break; + default: + break; } ::SetConsoleTextAttribute(::GetStdHandle(STD_OUTPUT_HANDLE), color); @@ -115,7 +119,7 @@ GUIConsole::~GUIConsole () {} void GUIConsole::SendLog(const std::string& notifiername, const std::string& msg, Base::LogStyle level) { (void) notifiername; - + switch(level){ case Base::LogStyle::Warning: std::cerr << "Warning: " << msg; diff --git a/src/Gui/Icons/InTray.svg b/src/Gui/Icons/InTray.svg new file mode 100644 index 0000000000..e9d0340b08 --- /dev/null +++ b/src/Gui/Icons/InTray.svg @@ -0,0 +1,487 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Jakub Steiner + + + http://jimmac.musichall.cz + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Gui/Icons/Warning.svg b/src/Gui/Icons/Warning.svg new file mode 100644 index 0000000000..9aab3a965a --- /dev/null +++ b/src/Gui/Icons/Warning.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/src/Gui/Icons/critical-info.svg b/src/Gui/Icons/critical-info.svg new file mode 100644 index 0000000000..a5236e240b --- /dev/null +++ b/src/Gui/Icons/critical-info.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Gui/Icons/info.svg b/src/Gui/Icons/info.svg new file mode 100644 index 0000000000..2fe7a0d9bf --- /dev/null +++ b/src/Gui/Icons/info.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Gui/Icons/resource.qrc b/src/Gui/Icons/resource.qrc index ccd411b1c5..39df69cbc5 100644 --- a/src/Gui/Icons/resource.qrc +++ b/src/Gui/Icons/resource.qrc @@ -74,6 +74,8 @@ edit-undo.svg edit-edit.svg edit-cleartext.svg + info.svg + critical-info.svg tree-item-drag.svg tree-goto-sel.svg tree-rec-sel.svg @@ -98,6 +100,7 @@ accessories-text-editor.svg accessories-calculator.svg internet-web-browser.svg + InTray.svg view-select.svg view-unselectable.svg view-refresh.svg @@ -251,6 +254,7 @@ Std_UserEditModeTransform.svg Std_UserEditModeCutting.svg Std_UserEditModeColor.svg + Warning.svg diff --git a/src/Gui/MainWindow.cpp b/src/Gui/MainWindow.cpp index 3f1ee3ad7b..cd038999d0 100644 --- a/src/Gui/MainWindow.cpp +++ b/src/Gui/MainWindow.cpp @@ -80,6 +80,7 @@ #include "DownloadManager.h" #include "FileDialog.h" #include "MenuManager.h" +#include "NotificationArea.h" #include "ProgressBar.h" #include "PropertyView.h" #include "PythonConsole.h" @@ -251,7 +252,6 @@ protected: } // namespace Gui - /* TRANSLATOR Gui::MainWindow */ MainWindow::MainWindow(QWidget * parent, Qt::WindowFlags f) @@ -304,6 +304,17 @@ MainWindow::MainWindow(QWidget * parent, Qt::WindowFlags f) statusBar()->addPermanentWidget(progressBar, 0); statusBar()->addPermanentWidget(d->sizeLabel, 0); + auto hGrp = App::GetApplication().GetParameterGroupByPath("User parameter:BaseApp/Preferences/NotificationArea"); + + auto notificationAreaEnabled = hGrp->GetBool("NotificationAreaEnabled", true); + + if(notificationAreaEnabled) { + NotificationArea* notificationArea = new NotificationArea(statusBar()); + notificationArea->setObjectName(QString::fromLatin1("notificationArea")); + notificationArea->setIcon(QIcon(QString::fromLatin1(":/icons/InTray.svg"))); + notificationArea->setStyleSheet(QStringLiteral("text-align:left;")); + statusBar()->addPermanentWidget(notificationArea); + } // clears the action label d->actionTimer = new QTimer( this ); d->actionTimer->setObjectName(QString::fromLatin1("actionTimer")); diff --git a/src/Gui/NotificationArea.cpp b/src/Gui/NotificationArea.cpp new file mode 100644 index 0000000000..84d943e6f2 --- /dev/null +++ b/src/Gui/NotificationArea.cpp @@ -0,0 +1,993 @@ +/*************************************************************************** + * Copyright (c) 2022 Abdullah Tahiri * + * * + * This file is part of the FreeCAD CAx development system. * + * * + * This library is free software; you can redistribute it and/or * + * modify it under the terms of the GNU Library General Public * + * License as published by the Free Software Foundation; either * + * version 2 of the License, or (at your option) any later version. * + * * + * This library 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 Library General Public License for more details. * + * * + * You should have received a copy of the GNU Library General Public * + * License along with this library; see the file COPYING.LIB. If not, * + * write to the Free Software Foundation, Inc., 59 Temple Place, * + * Suite 330, Boston, MA 02111-1307, USA * + * * + ***************************************************************************/ + +#include "PreCompiled.h" + +#ifndef _PreComp_ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +#include +#include + +#include "Application.h" +#include "BitmapFactory.h" +#include "MDIView.h" +#include "MainWindow.h" +#include "NotificationBox.h" + +#include "NotificationArea.h" + +using namespace Gui; + +using Connection = boost::signals2::connection; + +namespace bp = boost::placeholders; + +class NotificationAreaObserver; + +namespace Gui +{ +/** PImpl idiom structure having all data necessary for the notification area */ +struct NotificationAreaP +{ + // Structure holding all variables necessary for the Notification Area. + // Preference parameters are updated by NotificationArea::ParameterObserver + + /** @name Non-intrusive notifications parameters */ + //@{ + /// Parameter controlled + int maxOpenNotifications = 15; + /// Parameter controlled + unsigned int notificationExpirationTime = 10000; + /// minimum time that the notification will remain unclosed + unsigned int minimumOnScreenTime = 5000; + /// Parameter controlled + bool notificationsDisabled = false; + /// Control of confirmation mechanism (for Critical Messages) + bool requireConfirmationCriticalMessageDuringRestoring = true; + //@} + + /** @name Widget parameters */ + //@{ + /// Parameter controlled - maximum number of message allowed in the notification area widget (0 + /// means no limit) + int maxWidgetMessages = 1000; + /// User notifications get automatically removed from the Widget after the non-intrusive + /// notification expiration time + bool autoRemoveUserNotifications; + //@} + + /** @name Notification rate control */ + //@{ + /* Control rate of updates of non-intrusive messages (avoids visual artifacts when messages are + * constantly being received) */ + // Timer to delay notification until a minimum time between two consecutive messages have lapsed + QTimer inhibitTimer; + // The time between two consecutive messages forced by the inhibitTimer + const unsigned int inhibitNotificationTime = 250; + //@} + + // Access control + std::mutex mutexNotification; + + // Pointers to widgets (no ownership) + QMenu* menu; + QWidgetAction* notificationaction; + + /** @name Resources */ + //@{ + /// Base::Console Message observer + std::unique_ptr observer; + Connection finishRestoreDocumentConnection; + /// Preference Parameter observer + std::unique_ptr parameterObserver; + //@} +}; + +}// namespace Gui + + +/******************* Resource Management *****************************************/ + +/** Simple class to manage Notification Area Resources*/ +class ResourceManager +{ + +private: + ResourceManager() + { + error = BitmapFactory().pixmapFromSvg(":/icons/edit_Cancel.svg", QSize(16, 16)); + warning = BitmapFactory().pixmapFromSvg(":/icons/Warning.svg", QSize(16, 16)); + critical = BitmapFactory().pixmapFromSvg(":/icons/critical-info.svg", QSize(16, 16)); + info = BitmapFactory().pixmapFromSvg(":/icons/info.svg", QSize(16, 16)); + } + + inline static const auto& getResourceManager() + { + static ResourceManager manager; + + return manager; + } + +public: + inline static auto ErrorPixmap() + { + auto rm = getResourceManager(); + return rm.error; + } + + inline static auto WarningPixmap() + { + auto rm = getResourceManager(); + return rm.warning; + } + + inline static auto CriticalPixmap() + { + auto rm = getResourceManager(); + return rm.critical; + } + + inline static auto InfoPixmap() + { + auto rm = getResourceManager(); + return rm.info; + } + +private: + QPixmap error; + QPixmap warning; + QPixmap critical; + QPixmap info; +}; + +/******************** Console Messages Observer (Console Interface) ************************/ + +/** This class listens to all messages sent via the console interface and + feeds the non-intrusive notification system and the notifications widget */ +class NotificationAreaObserver: public Base::ILogger +{ +public: + NotificationAreaObserver(NotificationArea* notificationarea); + ~NotificationAreaObserver() override; + + /// Function that is called by the console interface for this observer with the message + /// information + void SendLog(const std::string& notifiername, const std::string& msg, + Base::LogStyle level) override; + + /// Name of the observer + const char* Name() override + { + return "NotificationAreaObserver"; + } + +private: + NotificationArea* notificationArea; +}; + +NotificationAreaObserver::NotificationAreaObserver(NotificationArea* notificationarea) + : notificationArea(notificationarea) +{ + Base::Console().AttachObserver(this); + bLog = false; // ignore log messages + bMsg = false; // ignore messages + bNotification = true; // activate user notifications + bTranslatedNotification = true;// activate translated user notifications +} + +NotificationAreaObserver::~NotificationAreaObserver() +{ + Base::Console().DetachObserver(this); +} + +void NotificationAreaObserver::SendLog(const std::string& notifiername, const std::string& msg, + Base::LogStyle level) +{ + // 1. As notification system is shared with report view and others, the expectation is that any + // individual error and warning message will end in "\n". This means the string must be stripped + // of this character for representation in the notification area. + // 2. However, any message marked with the QT_TRANSLATE_NOOT macro with the "Notifications" + // context, shall not include + // "\n", as this generates problems with the translation system. Then the string must be + // stripped of "\n" before translation. + + auto simplifiedstring = + QString::fromStdString(msg) + .trimmed();// remove any leading and trailing whitespace character ('\n') + + // avoid processing empty strings + if(simplifiedstring.isEmpty()) + return; + + if (level == Base::LogStyle::TranslatedNotification) { + notificationArea->pushNotification( + QString::fromStdString(notifiername), simplifiedstring, level); + } + else { + notificationArea->pushNotification( + QString::fromStdString(notifiername), + QCoreApplication::translate("Notifications", simplifiedstring.toUtf8()), + level); + } +} + + +/******************* Notification Widget *******************************************************/ + +/** Specialised Item class for the Widget notifications/errors/warnings + It holds all item specific data, including visualisation data and controls how + the item should appear in the widget. +*/ +class NotificationItem: public QTreeWidgetItem +{ +public: + NotificationItem(Base::LogStyle notificationtype, QString notifiername, QString message) + : notificationType(notificationtype), + notifierName(std::move(notifiername)), + msg(std::move(message)) + {} + + QVariant data(int column, int role) const override + { + // strings that will be displayed for each column of the widget + if (role == Qt::DisplayRole) { + switch (column) { + case 1: + return notifierName; + break; + case 2: + return msg; + break; + } + } + else if (column == 0 && role == Qt::DecorationRole) { + // Icons to be displayed for the first row + if (notificationType == Base::LogStyle::Error) { + return std::move(ResourceManager::ErrorPixmap()); + } + else if (notificationType == Base::LogStyle::Warning) { + return std::move(ResourceManager::WarningPixmap()); + } + else if (notificationType == Base::LogStyle::Critical) { + return std::move(ResourceManager::CriticalPixmap()); + } + else { + return std::move(ResourceManager::InfoPixmap()); + } + } + else if (role == Qt::FontRole) { + // Visualisation control of unread messages + static QFont font; + static QFont boldFont(font.family(), font.pointSize(), QFont::Bold); + + if (unread) { + return boldFont; + } + + return font; + } + + return QVariant(); + } + + Base::LogStyle notificationType; + QString notifierName; + QString msg; + + bool unread = true; // item is unread in the Notification Area Widget + bool notifying = true;// item is to be notified or being notified as non-intrusive message + bool shown = false; // item is already being notified (it is onScreen) +}; + +/** Drop menu Action containing the notifications widget. + * It stores all the notification item information in the form + * of NotificationItems (QTreeWidgetItem). This information is used + * by the Widget and by the non-intrusive messages. + * It owns the notification resources and is responsible for the release + * of the memory resources, either directly for the intermediate fast cache + * or indirectly via QT for the case of the QTreeWidgetItems. + */ +class NotificationsAction: public QWidgetAction +{ +public: + NotificationsAction(QWidget* parent) + : QWidgetAction(parent) + {} + + ~NotificationsAction() + { + for (auto* item : pushedItems) { + if (item) { + delete item; + } + } + } + +public: + /// deletes only notifications (messages of type Notification and TranslatedNotification) + void deleteNotifications() + { + if (tableWidget) { + for (int i = tableWidget->topLevelItemCount() - 1; i >= 0; i--) { + auto* item = static_cast(tableWidget->topLevelItem(i)); + if (item->notificationType == Base::LogStyle::Notification + || item->notificationType == Base::LogStyle::TranslatedNotification) { + delete item; + } + } + } + for (int i = pushedItems.size() - 1; i >= 0; i--) { + auto* item = static_cast(pushedItems.at(i)); + if (item->notificationType == Base::LogStyle::Notification + || item->notificationType == Base::LogStyle::TranslatedNotification) { + delete pushedItems.takeAt(i); + } + } + } + + /// deletes all notifications, errors and warnings + void deleteAll() + { + if (tableWidget) { + tableWidget->clear();// the parent will delete the items. + } + while (!pushedItems.isEmpty()) + delete pushedItems.takeFirst(); + } + + /// returns the amount of unread notifications, errors and warnings + inline int getUnreadCount() const + { + return getCurrently([](auto* item) { + return item->unread; + }); + } + + /// returns the amount of notifications, errors and warnings currently being notified + inline int getCurrentlyNotifyingCount() const + { + return getCurrently([](auto* item) { + return item->notifying; + }); + } + + /// returns the amount of notifications, errors and warnings currently being shown as + /// non-intrusive messages (on-screen) + inline int getShownCount() const + { + return getCurrently([](auto* item) { + return item->shown; + }); + } + + /// marks all notifications, errors and warnings as read + void clearUnreadFlag() + { + for (auto i = 0; i < tableWidget->topLevelItemCount(); + i++) {// all messages were read, so clear the unread flag + auto* item = static_cast(tableWidget->topLevelItem(i)); + item->unread = false; + } + } + + /// pushes all Notification Items to the Widget, so that they can be shown + void synchroniseWidget() + { + tableWidget->insertTopLevelItems(0, pushedItems); + pushedItems.clear(); + } + + /** pushes all Notification Items to the fast cache (this also prevents all unnecessary + * signaling from parents) and allows to accelerate insertions and deletions + */ + void shiftToCache() + { + tableWidget->blockSignals(true); + tableWidget->clearSelection(); + while (tableWidget->topLevelItemCount() > 0) { + auto* item = tableWidget->takeTopLevelItem(0); + pushedItems.push_back(item); + } + tableWidget->blockSignals(false); + } + + /// returns if there are no notifications, errors and warnings at all + bool isEmpty() const + { + return tableWidget->topLevelItemCount() == 0 && pushedItems.isEmpty(); + } + + /// returns the total amount of notifications, errors and warnings currently stored + auto count() const + { + return tableWidget->topLevelItemCount() + pushedItems.count(); + } + + /// retrieves a pointer to a given notification from storage. + auto getItem(int index) const + { + if (index < pushedItems.count()) { + return pushedItems.at(index); + } + else { + return tableWidget->topLevelItem(index - pushedItems.count()); + } + } + + /// deletes a given notification, errors or warnings by index + void deleteItem(int index) + { + if (index < pushedItems.count()) { + delete pushedItems.takeAt(index); + } + else { + delete tableWidget->topLevelItem(index - pushedItems.count()); + } + } + + /// deletes a given notification, errors or warnings by pointer + void deleteItem(NotificationItem* item) + { + for (int i = 0; i < count(); i++) { + if (getItem(i) == item) { + deleteItem(i); + return; + } + } + } + + /// deletes the last Notification Item + void deleteLastItem() + { + deleteItem(count() - 1); + } + + /// pushes a notification item to the front + void push_front(NotificationItem* item) + { + pushedItems.push_front(item); + } + +protected: + /// creates the Notifications Widget + QWidget* createWidget(QWidget* parent) override + { + QWidget* notificationsWidget = new QWidget(parent); + + QHBoxLayout* layout = new QHBoxLayout(notificationsWidget); + notificationsWidget->setLayout(layout); + + tableWidget = new QTreeWidget(parent); + tableWidget->setColumnCount(3); + + QStringList headers; + headers << QObject::tr("Type") << QObject::tr("Notifier") << QObject::tr("Message"); + tableWidget->setHeaderLabels(headers); + + layout->addWidget(tableWidget); + + tableWidget->setMaximumSize(1200, 600); + tableWidget->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); + tableWidget->header()->setStretchLastSection(false); + tableWidget->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + + tableWidget->setSelectionMode(QAbstractItemView::ExtendedSelection); + tableWidget->setContextMenuPolicy(Qt::CustomContextMenu); + + // context menu on any item (row) of the widget + QObject::connect( + tableWidget, &QTreeWidget::customContextMenuRequested, [&](const QPoint& pos) { + // auto item = tableWidget->itemAt(pos); + auto selectedItems = tableWidget->selectedItems(); + + QMenu menu; + + QAction* del = menu.addAction(tr("Delete"), this, [&]() { + for (auto it : selectedItems) { + delete it; + } + }); + + del->setEnabled(!selectedItems.isEmpty()); + + menu.addSeparator(); + + QAction* delnotifications = + menu.addAction(tr("Delete user notifications"), + this, + &NotificationsAction::deleteNotifications); + + delnotifications->setEnabled(tableWidget->topLevelItemCount() > 0); + + QAction* delall = + menu.addAction(tr("Delete All"), this, &NotificationsAction::deleteAll); + + delall->setEnabled(tableWidget->topLevelItemCount() > 0); + + menu.setDefaultAction(del); + + menu.exec(tableWidget->mapToGlobal(pos)); + }); + + return notificationsWidget; + } + +private: + /// utility function to return the number of Notification Items meeting the functor/lambda + /// criteria + int getCurrently(std::function F) const + { + int instate = 0; + for (auto i = 0; i < tableWidget->topLevelItemCount(); i++) { + auto* item = static_cast(tableWidget->topLevelItem(i)); + if (F(item)) { + instate++; + } + } + for (auto i = 0; i < pushedItems.count(); i++) { + auto* item = static_cast(pushedItems.at(i)); + if (F(item)) { + instate++; + } + } + return instate; + } + +private: + QTreeWidget* tableWidget; + // Intermediate storage + // Note: QTreeWidget is helplessly slow to single insertions, QTreeWidget is actually only + // necessary when showing the widget. A single QList insertion into a QTreeWidget is actually + // not that slow. The use of this intermediate storage substantially accelerates non-intrusive + // notifications. + QList pushedItems; +}; + +/************ Parameter Observer (preferences) **************************************/ + +NotificationArea::ParameterObserver::ParameterObserver(NotificationArea* notificationarea) + : notificationArea(notificationarea) +{ + hGrp = App::GetApplication().GetParameterGroupByPath( + "User parameter:BaseApp/Preferences/NotificationArea"); + + parameterMap = { + {"NotificationAreaEnabled", + [this](const std::string& string) { + auto enabled = hGrp->GetBool(string.c_str(), true); + if (!enabled) + notificationArea->deleteLater(); + }}, + {"NonIntrusiveNotificationsEnabled", + [this](const std::string& string) { + auto enabled = hGrp->GetBool(string.c_str(), true); + notificationArea->pImp->notificationsDisabled = !enabled; + }}, + {"NotificationTime", + [this](const std::string& string) { + auto time = hGrp->GetInt(string.c_str(), 20) * 1000; + if (time < 0) + time = 0; + notificationArea->pImp->notificationExpirationTime = static_cast(time); + }}, + {"MinimumOnScreenTime", + [this](const std::string& string) { + auto time = hGrp->GetInt(string.c_str(), 5) * 1000; + if (time < 0) + time = 0; + notificationArea->pImp->minimumOnScreenTime = static_cast(time); + }}, + {"MaxOpenNotifications", + [this](const std::string& string) { + auto limit = hGrp->GetInt(string.c_str(), 15); + if (limit < 0) + limit = 0; + notificationArea->pImp->maxOpenNotifications = static_cast(limit); + }}, + {"MaxWidgetMessages", + [this](const std::string& string) { + auto limit = hGrp->GetInt(string.c_str(), 1000); + if (limit < 0) + limit = 0; + notificationArea->pImp->maxWidgetMessages = static_cast(limit); + }}, + {"AutoRemoveUserNotifications", + [this](const std::string& string) { + auto enabled = hGrp->GetBool(string.c_str(), true); + notificationArea->pImp->autoRemoveUserNotifications = enabled; + }}, + }; + + for (auto& val : parameterMap) { + auto string = val.first; + auto update = val.second; + + update(string); + } + + hGrp->Attach(this); +} + +NotificationArea::ParameterObserver::~ParameterObserver() +{ + hGrp->Detach(this); +} + +void NotificationArea::ParameterObserver::OnChange(Base::Subject& rCaller, + const char* sReason) +{ + (void)rCaller; + + auto key = parameterMap.find(sReason); + + if (key != parameterMap.end()) { + auto string = key->first; + auto update = key->second; + + update(string); + } +} + +/************************* Notification Area *****************************************/ + +NotificationArea::NotificationArea(QWidget* parent) + : QPushButton(parent) +{ + // QPushButton appearance + setText(QString()); + setFlat(true); + + // Initialisation of pImpl structure + pImp = std::make_unique(); + + pImp->observer = std::make_unique(this); + pImp->parameterObserver = std::make_unique(this); + + pImp->menu = new QMenu(parent); + setMenu(pImp->menu); + + auto na = new NotificationsAction(pImp->menu); + + pImp->menu->addAction(na); + + pImp->notificationaction = na; + + // Signals for synchronisation of storage before showing/hiding the widget + QObject::connect(pImp->menu, &QMenu::aboutToHide, [&]() { + std::lock_guard g(pImp->mutexNotification); + static_cast(pImp->notificationaction)->clearUnreadFlag(); + static_cast(pImp->notificationaction)->shiftToCache(); + }); + + QObject::connect(pImp->menu, &QMenu::aboutToShow, [this]() { + std::lock_guard g( + pImp->mutexNotification);// guard to avoid modifying the notification list and indices + // while creating the tooltip + setText(QString::number(0)); // no unread notifications + static_cast(pImp->notificationaction)->synchroniseWidget(); + + // the position of the action has already been calculated (for a non-synchronised widget), + // the size could be recalculated here, but not the position according to the previous size. + // This resize event forces this recalculation. + QResizeEvent re(pImp->menu->size(), pImp->menu->size()); + qApp->sendEvent(pImp->menu, &re); + }); + + + // Connection to the finish restore signal to rearm Critical messages modal mode when action is + // user initiated + pImp->finishRestoreDocumentConnection = + App::GetApplication().signalFinishRestoreDocument.connect( + boost::bind(&Gui::NotificationArea::slotRestoreFinished, this, bp::_1)); + + // Initialisation of the timer to inhibit continuous updates of the notification system in case + // clusters of messages arrive (so as to delay the actual notification until the whole cluster + // has arrived) + pImp->inhibitTimer.setSingleShot(true); + + pImp->inhibitTimer.callOnTimeout([this, na]() { + setText(QString::number(na->getUnreadCount())); + showInNotificationArea(); + }); +} + +NotificationArea::~NotificationArea() +{ + pImp->finishRestoreDocumentConnection.disconnect(); +} + +void NotificationArea::mousePressEvent(QMouseEvent* e) +{ + // Contextual menu (e.g. to delete Notifications or all messages (Notifications, Errors, + // Warnings and Critical messages) + if (e->button() == Qt::RightButton && hitButton(e->pos())) { + QMenu menu; + + NotificationsAction* na = static_cast(pImp->notificationaction); + + QAction* delnotifications = menu.addAction(tr("Delete user notifications"), [&]() { + std::lock_guard g( + pImp->mutexNotification);// guard to avoid modifying the notification list and + // indices while creating the tooltip + na->deleteNotifications(); + setText(QString::number(na->getUnreadCount())); + }); + + delnotifications->setEnabled(!na->isEmpty()); + + QAction* delall = menu.addAction(tr("Delete All"), [&]() { + std::lock_guard g( + pImp->mutexNotification);// guard to avoid modifying the notification list and + // indices while creating the tooltip + na->deleteAll(); + setText(QString::number(0)); + }); + + delall->setEnabled(!na->isEmpty()); + + menu.setDefaultAction(delall); + + menu.exec(this->mapToGlobal(e->pos())); + } + QPushButton::mousePressEvent(e); +} + +void NotificationArea::pushNotification(const QString& notifiername, const QString& message, + Base::LogStyle level) +{ + auto* item = new NotificationItem(level, notifiername, message); + + bool confirmation = confirmationRequired(level); + + if (confirmation) { + showConfirmationDialog(notifiername, message); + } + + std::lock_guard g( + pImp->mutexNotification);// guard to avoid modifying the notification list and indices while + // creating the tooltip + + NotificationsAction* na = static_cast(pImp->notificationaction); + + // Limit the maximum number of messages stored in the widget (0 means no limit) + if (pImp->maxWidgetMessages != 0 && na->count() > pImp->maxWidgetMessages) { + na->deleteLastItem(); + } + + na->push_front(item); + + // If the non-intrusive notifications are disabled then stop here (messages added to the widget + // only) + if (pImp->notificationsDisabled) { + item->notifying = + false;// avoid mass of old notifications if feature is activated afterwards + setText(QString::number( + static_cast(pImp->notificationaction)->getUnreadCount())); + return; + } + + // start or restart rate control (the timer is rearmed if not yet expired, expiration triggers + // showing of the notification messages) + // + // NOTE: The inhibition timer is created in the main thread and cannot be restarted from another + // QThread. A QTimer can be moved to another QThread (from the QThread in which it is). However, + // it can only be create in a QThread, not in any other thread. + // + // For this reason, the timer is only triggered if this QThread is the QTimer thread. + // + // But I want my message from my thread to appear in the notification area. Fine, then configure + // Console not to use the direct connection mode, but the Queued one: + // Base::Console().SetConnectionMode(ConnectionMode::Queued); + + auto timer_thread = pImp->inhibitTimer.thread(); + auto current_thread = QThread::currentThread(); + + if(timer_thread == current_thread) + pImp->inhibitTimer.start(pImp->inhibitNotificationTime); +} + +bool NotificationArea::confirmationRequired(Base::LogStyle level) +{ + auto userInitiatedRestore = + Application::Instance->testStatus(Gui::Application::UserInitiatedOpenDocument); + + return (level == Base::LogStyle::Critical && userInitiatedRestore + && pImp->requireConfirmationCriticalMessageDuringRestoring); +} + +void NotificationArea::showConfirmationDialog(const QString& notifiername, const QString& message) +{ + auto confirmMsg = QObject::tr("Notifier: ") + notifiername + QStringLiteral("\n\n") + message + + QStringLiteral("\n\n") + + QObject::tr("Do you want to skip confirmation of further critical message notifications " + "while loading the file?"); + + auto button = QMessageBox::critical(getMainWindow()->activeWindow(), + QObject::tr("Critical Message"), + confirmMsg, + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); + + if (button == QMessageBox::Yes) + pImp->requireConfirmationCriticalMessageDuringRestoring = false; +} + +void NotificationArea::showInNotificationArea() +{ + std::lock_guard g( + pImp->mutexNotification);// guard to avoid modifying the notification list and indices while + // creating the tooltip + + NotificationsAction* na = static_cast(pImp->notificationaction); + + if (!NotificationBox::isVisible()) { + // The Notification Box may have been closed (by the user by popping it out or by left mouse + // button) ensure that old notifications are not shown again, even if the timer has not + // lapsed + int i = 0; + while (i < na->count() && static_cast(na->getItem(i))->notifying) { + NotificationItem* item = static_cast(na->getItem(i)); + + if (item->shown) { + item->notifying = false; + item->shown = false; + } + + i++; + } + } + + auto currentlyshown = na->getShownCount(); + + // If we cannot show more messages, we do no need to update the non-intrusive notification + if (currentlyshown < pImp->maxOpenNotifications) { + // There is space for at least one more notification + // We update the message with the most recent up to maxOpenNotifications + + QString msgw = + QString::fromLatin1( + " \ +

\ + \ + \ + \ + \ + \ + ") + .arg(QObject::tr("Type")) + .arg(QObject::tr("Notifier")) + .arg(QObject::tr("Message")); + + auto currentlynotifying = na->getCurrentlyNotifyingCount(); + + if (currentlynotifying > pImp->maxOpenNotifications) { + msgw += + QString::fromLatin1( + " \ + \ + \ + \ + \ + ") + .arg(QObject::tr("Too many opened non-intrusive notifications. Notifications " + "are being omitted!")); + } + + int i = 0; + + while (i < na->count() && static_cast(na->getItem(i))->notifying) { + + if (i < pImp->maxOpenNotifications) {// show the first up to maxOpenNotifications + NotificationItem* item = static_cast(na->getItem(i)); + + QString iconstr; + if (item->notificationType == Base::LogStyle::Error) { + iconstr = QStringLiteral(":/icons/edit_Cancel.svg"); + } + else if (item->notificationType == Base::LogStyle::Warning) { + iconstr = QStringLiteral(":/icons/Warning.svg"); + } + else if (item->notificationType == Base::LogStyle::Critical) { + iconstr = QStringLiteral(":/icons/critical-info.svg"); + } + else { + iconstr = QStringLiteral(":/icons/info.svg"); + } + + msgw += + QString::fromLatin1( + " \ + \ + \ + \ + \ + ") + .arg(iconstr) + .arg(item->notifierName) + .arg(item->msg); + + // start a timer for each of these notifications that was not previously shown + if (!item->shown) { + QTimer::singleShot(pImp->notificationExpirationTime, [this, item]() { + std::lock_guard g( + pImp->mutexNotification);// guard to avoid modifying the notification + // start index while creating the tooltip + + if (item) { + item->shown = false; + item->notifying = false; + + if (pImp->autoRemoveUserNotifications) { + if (item->notificationType == Base::LogStyle::Notification + || item->notificationType + == Base::LogStyle::TranslatedNotification) { + + static_cast(pImp->notificationaction) + ->deleteItem(item); + } + } + } + }); + } + + // We update the status to shown + item->shown = true; + } + else {// We do not have more space and older notifications will be too old + static_cast(na->getItem(i))->notifying = false; + static_cast(na->getItem(i))->shown = false; + } + + i++; + } + + msgw += QString::fromLatin1("
%1%2%3
FreeCAD%1
%2%3

"); + + + NotificationBox::showText(this->mapToGlobal(QPoint()), + msgw, + pImp->notificationExpirationTime, + pImp->minimumOnScreenTime); + } +} + +void NotificationArea::slotRestoreFinished(const App::Document&) +{ + // Re-arm on restore critical message modal notifications if another document is loaded + pImp->requireConfirmationCriticalMessageDuringRestoring = true; +} diff --git a/src/Gui/NotificationArea.h b/src/Gui/NotificationArea.h new file mode 100644 index 0000000000..133cb41737 --- /dev/null +++ b/src/Gui/NotificationArea.h @@ -0,0 +1,78 @@ +/*************************************************************************** + * Copyright (c) 2022 Abdullah Tahiri * + * * + * This file is part of the FreeCAD CAx development system. * + * * + * This library is free software; you can redistribute it and/or * + * modify it under the terms of the GNU Library General Public * + * License as published by the Free Software Foundation; either * + * version 2 of the License, or (at your option) any later version. * + * * + * This library 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 Library General Public License for more details. * + * * + * You should have received a copy of the GNU Library General Public * + * License along with this library; see the file COPYING.LIB. If not, * + * write to the Free Software Foundation, Inc., 59 Temple Place, * + * Suite 330, Boston, MA 02111-1307, USA * + * * + ***************************************************************************/ + +#ifndef GUI_NOTIFICATIONAREA_H +#define GUI_NOTIFICATIONAREA_H + +#include +#include + +#include +#include + +#include +#include + +namespace Gui +{ + +struct NotificationAreaP; + +class NotificationArea: public QPushButton +{ +public: + class ParameterObserver: public ParameterGrp::ObserverType + { + public: + explicit ParameterObserver(NotificationArea* notificationarea); + ~ParameterObserver(); + + void OnChange(Base::Subject& rCaller, const char* sReason) override; + + private: + NotificationArea* notificationArea; + ParameterGrp::handle hGrp; + std::map> parameterMap; + }; + + NotificationArea(QWidget* parent = nullptr); + ~NotificationArea(); + + void pushNotification(const QString& notifiername, const QString& message, + Base::LogStyle level); + +private: + void showInNotificationArea(); + bool confirmationRequired(Base::LogStyle level); + void showConfirmationDialog(const QString& notifiername, const QString& message); + void slotRestoreFinished(const App::Document&); + + void mousePressEvent(QMouseEvent* e) override; + +private: + std::unique_ptr pImp; +}; + + +}// namespace Gui + +#endif// GUI_NOTIFICATIONAREA_H diff --git a/src/Gui/NotificationBox.cpp b/src/Gui/NotificationBox.cpp index 6f0bef9e44..d74b64584a 100644 --- a/src/Gui/NotificationBox.cpp +++ b/src/Gui/NotificationBox.cpp @@ -22,16 +22,16 @@ #include "PreCompiled.h" #ifndef _PreComp_ -# include -# include -# include -# include -# include -# include -# include -# include -# include -# include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #endif #include "NotificationBox.h" @@ -39,11 +39,14 @@ using namespace Gui; -namespace Gui { +namespace Gui +{ // https://stackoverflow.com/questions/41402152/stdunique-ptr-and-qobjectdeletelater -struct QObjectDeleteLater { - void operator()(QObject *o) { +struct QObjectDeleteLater +{ + void operator()(QObject* o) + { o->deleteLater(); } }; @@ -53,32 +56,31 @@ using qobject_delete_later_unique_ptr = std::unique_ptr; /** Class showing the notification as a label * */ -class NotificationLabel : public QLabel +class NotificationLabel: public QLabel { Q_OBJECT public: - // Windows implementation uses QWidget w to pass the screen (see NotificationBox::showText). - // This screen is used as parent for QLabel. - // Linux implementation does not rely on a parent (w = nullptr). - NotificationLabel(const QString &text, const QPoint &pos, QWidget *w, int displayTime, int minShowTime = 0); + NotificationLabel(const QString& text, const QPoint& pos, int displayTime, int minShowTime = 0); /// Reuse existing notification to show a new notification (with a new text) - void reuseNotification(const QString &text, int displayTime, const QPoint &pos); + void reuseNotification(const QString& text, int displayTime, const QPoint& pos); /// Hide notification after a hiding timer. void hideNotification(); /// Update the size of the QLabel - void updateSize(const QPoint &pos); + void updateSize(const QPoint& pos); /// Event filter - bool eventFilter(QObject *, QEvent *) override; + bool eventFilter(QObject*, QEvent*) override; /// Return true if the text provided is the same as the one of an existing notification - bool notificationLabelChanged(const QString &text); + bool notificationLabelChanged(const QString& text); /// Place the notification at the given position - void placeNotificationLabel(const QPoint &pos); + void placeNotificationLabel(const QPoint& pos); /// The instance static qobject_delete_later_unique_ptr instance; + protected: - void paintEvent(QPaintEvent *e) override; - void resizeEvent(QResizeEvent *e) override; + void paintEvent(QPaintEvent* e) override; + void resizeEvent(QResizeEvent* e) override; + private: /// Re-start the notification expiration timer void restartExpireTimer(int displayTime); @@ -93,12 +95,14 @@ private: qobject_delete_later_unique_ptr NotificationLabel::instance = nullptr; -NotificationLabel::NotificationLabel(const QString &text, const QPoint &pos, QWidget *w, int displayTime, int minShowTime) -: QLabel(w, Qt::ToolTip | Qt::BypassGraphicsProxyWidget), minShowTime(minShowTime) +NotificationLabel::NotificationLabel(const QString& text, const QPoint& pos, int displayTime, + int minShowTime) + : QLabel(nullptr, Qt::ToolTip | Qt::BypassGraphicsProxyWidget), + minShowTime(minShowTime) { instance.reset(this); - setForegroundRole(QPalette::ToolTipText); // defaults to ToolTip QPalette - setBackgroundRole(QPalette::ToolTipBase); // defaults to ToolTip QPalette + setForegroundRole(QPalette::ToolTipText);// defaults to ToolTip QPalette + setBackgroundRole(QPalette::ToolTipBase);// defaults to ToolTip QPalette setPalette(NotificationBox::palette()); ensurePolished(); setMargin(1 + style()->pixelMetric(QStyle::PM_ToolTipLabelFrameWidth, nullptr, this)); @@ -111,12 +115,12 @@ NotificationLabel::NotificationLabel(const QString &text, const QPoint &pos, QWi hideTimer.setSingleShot(true); expireTimer.setSingleShot(true); - expireTimer.callOnTimeout([this](){ + expireTimer.callOnTimeout([this]() { hideTimer.stop(); hideNotificationImmediately(); }); - hideTimer.callOnTimeout([this](){ + hideTimer.callOnTimeout([this]() { expireTimer.stop(); hideNotificationImmediately(); }); @@ -132,21 +136,21 @@ void NotificationLabel::restartExpireTimer(int displayTime) time = displayTime; } else { - time = 10000 + 40 * qMax(0, text().length()-100); + time = 10000 + 40 * qMax(0, text().length() - 100); } expireTimer.start(time); hideTimer.stop(); } -void NotificationLabel::reuseNotification(const QString &text, int displayTime, const QPoint &pos) +void NotificationLabel::reuseNotification(const QString& text, int displayTime, const QPoint& pos) { setText(text); updateSize(pos); restartExpireTimer(displayTime); } -void NotificationLabel::updateSize(const QPoint &pos) +void NotificationLabel::updateSize(const QPoint& pos) { // Ensure that we get correct sizeHints by placing this window on the right screen. QFontMetrics fm(font()); @@ -161,11 +165,13 @@ void NotificationLabel::updateSize(const QPoint &pos) QSize sh = sizeHint(); // ### When the above WinRT code is fixed, windowhandle should be used to find the screen. - QScreen *screen = QGuiApplication::screenAt(pos); + QScreen* screen = QGuiApplication::screenAt(pos); + if (!screen) { screen = QGuiApplication::primaryScreen(); } - else { + + if (screen) { const qreal screenWidth = screen->geometry().width(); if (!wordWrap() && sh.width() > screenWidth) { setWordWrap(true); @@ -176,7 +182,7 @@ void NotificationLabel::updateSize(const QPoint &pos) resize(sh + extra); } -void NotificationLabel::paintEvent(QPaintEvent *ev) +void NotificationLabel::paintEvent(QPaintEvent* ev) { QStylePainter p(this); QStyleOptionFrame opt; @@ -186,7 +192,7 @@ void NotificationLabel::paintEvent(QPaintEvent *ev) QLabel::paintEvent(ev); } -void NotificationLabel::resizeEvent(QResizeEvent *e) +void NotificationLabel::resizeEvent(QResizeEvent* e) { QStyleHintReturnMask frameMask; QStyleOption option; @@ -209,24 +215,25 @@ void NotificationLabel::hideNotification() void NotificationLabel::hideNotificationImmediately() { - close(); // to trigger QEvent::Close which stops the animation + close();// to trigger QEvent::Close which stops the animation instance = nullptr; } -bool NotificationLabel::eventFilter(QObject *o, QEvent *e) +bool NotificationLabel::eventFilter(QObject* o, QEvent* e) { Q_UNUSED(o) switch (e->type()) { - case QEvent::MouseButtonPress: - { - // If minimum on screen time has already lapsed - hide the notification no matter where the click was done + case QEvent::MouseButtonPress: { + // If minimum on screen time has already lapsed - hide the notification no matter where + // the click was done auto total = expireTimer.interval(); auto remaining = expireTimer.remainingTime(); auto lapsed = total - remaining; - // ... or if the click is inside the notification, hide it no matter if the minimum onscreen time has lapsed or not + // ... or if the click is inside the notification, hide it no matter if the minimum + // onscreen time has lapsed or not auto insideclick = this->underMouse(); - if( lapsed > minShowTime || insideclick) { + if (lapsed > minShowTime || insideclick) { hideNotification(); return insideclick; @@ -238,10 +245,10 @@ bool NotificationLabel::eventFilter(QObject *o, QEvent *e) return false; } -void NotificationLabel::placeNotificationLabel(const QPoint &pos) +void NotificationLabel::placeNotificationLabel(const QPoint& pos) { QPoint p = pos; - const QScreen *screen = QGuiApplication::screenAt(pos); + const QScreen* screen = QGuiApplication::screenAt(pos); // a QScreen's handle *should* never be null, so this is a bit paranoid if (screen && screen->handle()) { const QSize cursorSize = QSize(16, 16); @@ -272,24 +279,25 @@ void NotificationLabel::placeNotificationLabel(const QPoint &pos) this->move(p); } -bool NotificationLabel::notificationLabelChanged(const QString &text) +bool NotificationLabel::notificationLabelChanged(const QString& text) { return NotificationLabel::instance->text() != text; } /***************************** NotificationBox **********************************/ -void NotificationBox::showText(const QPoint &pos, const QString &text, int displayTime, unsigned int minShowTime) +void NotificationBox::showText(const QPoint& pos, const QString& text, int displayTime, + unsigned int minShowTime) { // a label does already exist - if (NotificationLabel::instance && NotificationLabel::instance->isVisible()){ - if (text.isEmpty()){ // empty text means hide current label + if (NotificationLabel::instance && NotificationLabel::instance->isVisible()) { + if (text.isEmpty()) {// empty text means hide current label NotificationLabel::instance->hideNotification(); return; } else { // If the label has changed, reuse the one that is showing (removes flickering) - if (NotificationLabel::instance->notificationLabelChanged(text)){ + if (NotificationLabel::instance->notificationLabelChanged(text)) { NotificationLabel::instance->reuseNotification(text, displayTime, pos); NotificationLabel::instance->placeNotificationLabel(pos); } @@ -299,24 +307,11 @@ void NotificationBox::showText(const QPoint &pos, const QString &text, int displ // no label can be reused, create new label: if (!text.isEmpty()) { -#ifdef Q_OS_WIN32 - // On windows, we can't use the widget as parent otherwise the window will be - // raised when the toollabel will be shown - QT_WARNING_PUSH - QT_WARNING_DISABLE_DEPRECATED new NotificationLabel(text, pos, - nullptr, - displayTime, - minShowTime);// NotificationLabel manages its own lifetime. - QT_WARNING_POP -#else - new NotificationLabel(text, - pos, - nullptr, displayTime, minShowTime);// sets NotificationLabel::instance to itself -#endif + NotificationLabel::instance->placeNotificationLabel(pos); NotificationLabel::instance->setObjectName(QLatin1String("NotificationBox_label")); @@ -348,18 +343,18 @@ QFont NotificationBox::font() return QApplication::font("NotificationLabel"); } -void NotificationBox::setPalette(const QPalette &palette) +void NotificationBox::setPalette(const QPalette& palette) { *notificationbox_palette() = palette; if (NotificationLabel::instance) NotificationLabel::instance->setPalette(palette); } -void NotificationBox::setFont(const QFont &font) +void NotificationBox::setFont(const QFont& font) { QApplication::setFont(font, "NotificationLabel"); } -} // namespace Gui +}// namespace Gui #include "NotificationBox.moc" diff --git a/src/Gui/NotificationBox.h b/src/Gui/NotificationBox.h index 9c4f227565..e824fe4125 100644 --- a/src/Gui/NotificationBox.h +++ b/src/Gui/NotificationBox.h @@ -24,52 +24,59 @@ #define GUI_NOTIFICATIONBOX_H +namespace Gui +{ -namespace Gui { +/** This class provides a non-intrusive tip alike notification + * dialog, which unlike QToolTip, is kept shown during a time. + * + * The notification is shown during minShowTime, unless pop out + * (i.e. clicked inside the notification). + * + * The notification will show up to a maximum of displayTime. The + * only event that closes the notification between minShowTime and + * displayTime is a mouse button click (anywhere of the screen). + * + * When displayTime is not provided, it is calculated based on the length + * of the text. + * + * This class interface and its implementation are based on QT's + * QToolTip. + */ +class NotificationBox +{ + NotificationBox() = delete; - /** This class provides a non-intrusive tip alike notification - * dialog, which unlike QToolTip, is kept shown during a time. - * - * The notification is shown during minShowTime, unless pop out - * (i.e. clicked inside the notification). - * - * The notification will show up to a maximum of displayTime. The - * only event that closes the notification between minShowTime and - * displayTime is a mouse button click (anywhere of the screen). - * - * When displayTime is not provided, it is calculated based on the length - * of the text. - * - * This class interface and its implementation are based on QT's - * QToolTip. +public: + /** Shows a non-intrusive notification. + * @param pos Position at which the notification will be shown + * @param displayTime Time after which the notification will auto-close (unless it is closed by + * an event, see class documentation above) + * @param minShowTime Time during which the notification can only be made disappear by popping + * it out (clicking inside it). */ - class NotificationBox + static void showText(const QPoint& pos, const QString& text, int displayTime = -1, + unsigned int minShowTime = 0); + /// Hides a notification. + static inline void hideText() { - NotificationBox() = delete; - public: - /** Shows a non-intrusive notification. - * @param pos Position at which the notification will be shown - * @param displayTime Time after which the notification will auto-close (unless it is closed by an event, see class documentation above) - * @param minShowTime Time during which the notification can only be made disappear by popping it out (clicking inside it). - */ - static void showText(const QPoint &pos, const QString &text, int displayTime = -1, unsigned int minShowTime = 0); - /// Hides a notification. - static inline void hideText() { showText(QPoint(), QString()); } - /// Returns whether a notification is being shown or not. - static bool isVisible(); - /// Returns the text of the notification. - static QString text(); - /// Returns the palette. - static QPalette palette(); - /// Sets the palette. - static void setPalette(const QPalette &); - /// Returns the font of the notification. - static QFont font(); - /// Sets the font to be used in the notification. - static void setFont(const QFont &); - }; + showText(QPoint(), QString()); + } + /// Returns whether a notification is being shown or not. + static bool isVisible(); + /// Returns the text of the notification. + static QString text(); + /// Returns the palette. + static QPalette palette(); + /// Sets the palette. + static void setPalette(const QPalette&); + /// Returns the font of the notification. + static QFont font(); + /// Sets the font to be used in the notification. + static void setFont(const QFont&); +}; -} // namespace Gui +}// namespace Gui -#endif // GUI_NOTIFICATIONBOX_H +#endif// GUI_NOTIFICATIONBOX_H diff --git a/src/Gui/Notifications.h b/src/Gui/Notifications.h new file mode 100644 index 0000000000..d805f38bf0 --- /dev/null +++ b/src/Gui/Notifications.h @@ -0,0 +1,219 @@ +/*************************************************************************** + * Copyright (c) 2023 Abdullah Tahiri * + * * + * This file is part of the FreeCAD CAx development system. * + * * + * This library is free software; you can redistribute it and/or * + * modify it under the terms of the GNU Library General Public * + * License as published by the Free Software Foundation; either * + * version 2 of the License, or (at your option) any later version. * + * * + * This library 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 Library General Public License for more details. * + * * + * You should have received a copy of the GNU Library General Public * + * License along with this library; see the file COPYING.LIB. If not, * + * write to the Free Software Foundation, Inc., 59 Temple Place, * + * Suite 330, Boston, MA 02111-1307, USA * + * * + ***************************************************************************/ + +#ifndef GUI_NOTIFICATIONS_H +#define GUI_NOTIFICATIONS_H + +#include +#include + +#include +#include +#include +#include + +namespace Gui { + +/** Methods to seamlessly provide intrusive or non-intrusive notifications of error, warning, + * messages, translated notifications, or untranslated notifications originating in a given + * document object. + * + * They are intended for easy substitution of currently blocking modal dialogs in which the user + * may only click 'ok'. + * + * It produces a blocking modal notification or a non-intrusive non-modal notification depending on + * the preference parameter NotificationArea/NonIntrusiveNotificationsEnabled. + * + * The notifier field can be a string or an App::DocumentObject * object, then the notifier is taken from the + * getFullLabel() method of the DocumentObject. + * + * Translations: + * + * An attempt is made by NotificationArea to translate the message using the "Notifications" context, + * except for TranslatedNotification. + * + * For the former, this may be marked using QT_TRANSLATE_NOOP("Notifications","My message") + * + * For TranslatedNotification, many modules using blocking notifications have their translations stored + * in other contexts, and the translations available at the callee function. This kind of notification + * provides a very low entry point to move existing blocking notifications into non-intrusive respecting + * the user choice (given by NotificationArea/NonIntrusiveNotificationsEnabled). + * + * Example 1: + * + * QMessageBox::warning(Gui::getMainWindow(), QObject::tr("Wrong selection"), + * QObject::tr("Cannot add a constraint between two external geometries.")); + * + * Can be rewritten as: + * + * Gui::TranslatedNotification(obj, + * QObject::tr("Wrong selection"), + * QObject::tr("Cannot add a constraint between two external geometries.")); + * + * Here obj is a DocumentObject and serves to set the Notifier field for the notification. + * If the user preference is to see non-intrusive notifications, no pop-up will be shown and the + * notification will be shown in the notifications area. If the user preference is to see intrusive + * notifications, the pop-up will be shown (and it won't appear on the notification area as as a non- + * intrusive notification). + */ + +///generic function to send any message provided by Base::LogStyle +template +inline void Notify(TNotifier && notifier, TCaption && caption, TMessage && message); + +/** Convenience function to notify warnings + * The NotificationArea will attempt to find a translation in the "Notifications" context. + * This may be marked using QT_TRANSLATE_NOOP("Notifications","My message") + */ +template +inline void NotifyWarning(TNotifier && notifier, TCaption && caption, TMessage && message); + +/** Convenience function to notify errors + * The NotificationArea will attempt to find a translation in the "Notifications" context. + * This may be marked using QT_TRANSLATE_NOOP("Notifications","My message") + */ +template +inline void NotifyError(TNotifier && notifier, TCaption && caption, TMessage && message); + +/** Convenience function to notify messages + * The NotificationArea will attempt to find a translation in the "Notifications" context. + * This may be marked using QT_TRANSLATE_NOOP("Notifications","My message") + */ +template +inline void NotifyMessage(TNotifier && notifier, TCaption && caption, TMessage && message); + +/** Convenience function to send already translated user notifications. + * No attempt will be made by the NotificationArea to translate them. + */ +template +inline void TranslatedNotification(TNotifier && notifier, TCaption && caption, TMessage && message); + +/** Convenience function to send untranslated user notifications. + * The NotificationArea will attempt to find a translation in the "Notifications" context. + * This may be marked using QT_TRANSLATE_NOOP("Notifications","My message") + */ +template +inline void Notification(TNotifier && notifier, TCaption && caption, TMessage && message); +} //namespace Gui + + +template +inline void Gui::Notify(TNotifier && notifier, TCaption && caption, TMessage && message) +{ + Base::Reference hGrp = App::GetApplication().GetUserParameter(). + GetGroup("BaseApp")->GetGroup("Preferences")-> + GetGroup("NotificationArea"); + + bool nonIntrusive = hGrp->GetBool("NonIntrusiveNotificationsEnabled", true); + + if(!nonIntrusive) { + if constexpr( type == Base::LogStyle::Warning) { + QMessageBox::warning(Gui::getMainWindow(), + QCoreApplication::translate("Notifications", caption), + QCoreApplication::translate("Notifications", message)); + } + else + if constexpr( type == Base::LogStyle::Error) { + QMessageBox::critical(Gui::getMainWindow(), + QCoreApplication::translate("Notifications", caption), + QCoreApplication::translate("Notifications", message)); + } + else + if constexpr( type == Base::LogStyle::TranslatedNotification) { + QMessageBox::information(Gui::getMainWindow(), + caption, + message); + } + else { + QMessageBox::information(Gui::getMainWindow(), + QCoreApplication::translate("Notifications", caption), + QCoreApplication::translate("Notifications", message)); + } + } + else { + + if constexpr( type == Base::LogStyle::TranslatedNotification) { + // trailing newline is necessary as this may be shown too in a console requiring them (depending on the configuration). + auto msg = message.append(QStringLiteral("\n")); // QString + + if constexpr( std::is_base_of_v::type>> ) { + Base::Console().Send(notifier->getFullLabel(), msg.toUtf8()); + } + else { + Base::Console().Send(notifier, msg.toUtf8()); + } + } + else { + // trailing newline is necessary as this may be shown too in a console requiring them (depending on the configuration). + auto msg = std::string(message).append("\n"); + + if constexpr( std::is_base_of_v::type>> ) { + Base::Console().Send(notifier->getFullLabel(), msg.c_str()); + } + else { + Base::Console().Send(notifier, msg.c_str()); + } + } + } +} + +template +inline void Gui::NotifyWarning(TNotifier && notifier, TCaption && caption, TMessage && message) +{ + Notify(std::forward(notifier), + std::forward(caption), + std::forward(message)); +} + +template +inline void Gui::NotifyError(TNotifier && notifier, TCaption && caption, TMessage && message) +{ + Notify( std::forward(notifier), + std::forward(caption), + std::forward(message)); +} + +template +inline void Gui::NotifyMessage(TNotifier && notifier, TCaption && caption, TMessage && message) +{ + Notify(std::forward(notifier), + std::forward(caption), + std::forward(message)); +} + +template +inline void Gui::TranslatedNotification(TNotifier && notifier, TCaption && caption, TMessage && message) +{ + Notify(std::forward(notifier), + std::forward(caption), + std::forward(message)); +} + +template +inline void Gui::Notification(TNotifier && notifier, TCaption && caption, TMessage && message) +{ + Notify(std::forward(notifier), + std::forward(caption), + std::forward(message)); +} + +#endif // GUI_NOTIFICATIONS_H diff --git a/src/Gui/PreCompiled.h b/src/Gui/PreCompiled.h index f54e96ae5a..2c06bba847 100644 --- a/src/Gui/PreCompiled.h +++ b/src/Gui/PreCompiled.h @@ -96,8 +96,9 @@ #include #include -#include "InventorAll.h" +// Keep this order to avoid compiler warnings #include "QtAll.h" +#include "InventorAll.h" #elif defined(FC_OS_WIN32) #include diff --git a/src/Gui/PrefWidgets.cpp b/src/Gui/PrefWidgets.cpp index 35e6fda65b..0b573116c7 100644 --- a/src/Gui/PrefWidgets.cpp +++ b/src/Gui/PrefWidgets.cpp @@ -547,8 +547,7 @@ PrefColorButton::~PrefColorButton() void PrefColorButton::restorePreferences() { - if (getWindowParameter().isNull()) - { + if (getWindowParameter().isNull()) { failedToRestore(objectName()); return; } @@ -559,11 +558,11 @@ void PrefColorButton::restorePreferences() unsigned int icol = App::Color::asPackedRGBA(m_Default); unsigned long lcol = static_cast(icol); - lcol = getWindowParameter()->GetUnsigned( entryName(), lcol ); + lcol = getWindowParameter()->GetUnsigned(entryName(), lcol); icol = static_cast(lcol); - QColor value = App::Color::fromPackedRGB(icol); + QColor value = App::Color::fromPackedRGBA(icol); if (!this->allowTransparency()) - value.setAlpha(0xff); + value.setAlpha(0xff); setColor(value); } diff --git a/src/Gui/PreferencePackManager.cpp b/src/Gui/PreferencePackManager.cpp index ab2145fd3c..e26ab978bf 100644 --- a/src/Gui/PreferencePackManager.cpp +++ b/src/Gui/PreferencePackManager.cpp @@ -235,7 +235,7 @@ void Gui::PreferencePackManager::FindPreferencePacksInPackage(const fs::path &mo try { App::Metadata metadata(packageMetadataFile); auto content = metadata.content(); - auto basename = mod.leaf().string(); + auto basename = mod.filename().string(); if (mod == modDirectory) basename = "##USER_SAVED##"; else if (mod == resourcePath) diff --git a/src/Gui/QtAll.h b/src/Gui/QtAll.h index 3139f58415..0d570a641e 100644 --- a/src/Gui/QtAll.h +++ b/src/Gui/QtAll.h @@ -191,6 +191,7 @@ #include #include #include +#include // QtXML #include diff --git a/src/Gui/Splashscreen.cpp b/src/Gui/Splashscreen.cpp index 20840ca58b..a2f4062877 100644 --- a/src/Gui/Splashscreen.cpp +++ b/src/Gui/Splashscreen.cpp @@ -801,14 +801,14 @@ void AboutDialog::on_copyButton_clicked() bool firstMod = true; if (fs::exists(modDir) && fs::is_directory(modDir)) { for (const auto& mod : fs::directory_iterator(modDir)) { - auto dirName = mod.path().leaf().string(); + auto dirName = mod.path().filename().string(); if (dirName[0] == '.') // Ignore dot directories continue; if (firstMod) { firstMod = false; str << "Installed mods: \n"; } - str << " * " << QString::fromStdString(mod.path().leaf().string()); + str << " * " << QString::fromStdString(mod.path().filename().string()); auto metadataFile = mod.path() / "package.xml"; if (fs::exists(metadataFile)) { App::Metadata metadata(metadataFile); diff --git a/src/Gui/Stylesheets/Behave-dark.qss b/src/Gui/Stylesheets/Behave-dark.qss index fdf97a21c1..13ef21cb80 100644 --- a/src/Gui/Stylesheets/Behave-dark.qss +++ b/src/Gui/Stylesheets/Behave-dark.qss @@ -1648,6 +1648,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #65A2E5, stop:1 #65A2E5); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: #D2D8E1; + background: #232932; + padding: 0px; + margin: 0px; +} + /*================================================================================================== Radio button diff --git a/src/Gui/Stylesheets/Dark-blue.qss b/src/Gui/Stylesheets/Dark-blue.qss index b004f7891f..12f8edb93f 100644 --- a/src/Gui/Stylesheets/Dark-blue.qss +++ b/src/Gui/Stylesheets/Dark-blue.qss @@ -1615,6 +1615,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #3874f2, stop:1 #5e90fa); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: #e0e0e0; + background-color: #5a5a5a; + padding: 0px; + margin: 0px; +} + /*================================================================================================== Radio button diff --git a/src/Gui/Stylesheets/Dark-contrast.qss b/src/Gui/Stylesheets/Dark-contrast.qss index 3606d183ce..acf351997c 100644 --- a/src/Gui/Stylesheets/Dark-contrast.qss +++ b/src/Gui/Stylesheets/Dark-contrast.qss @@ -1615,6 +1615,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #1b3774, stop:1 #2053c0); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: #fefefe; + background-color: #111111; + padding: 0px; + margin: 0px; +} + /*================================================================================================== QComboBox inside Task Panel content diff --git a/src/Gui/Stylesheets/Dark-green.qss b/src/Gui/Stylesheets/Dark-green.qss index 1f090bb4dd..ed6ab3923b 100644 --- a/src/Gui/Stylesheets/Dark-green.qss +++ b/src/Gui/Stylesheets/Dark-green.qss @@ -1614,6 +1614,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #819c0c, stop:1 #94b30f); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: #e0e0e0; + background-color: #5a5a5a; + padding: 0px; + margin: 0px; +} + /*================================================================================================== Radio button diff --git a/src/Gui/Stylesheets/Dark-orange.qss b/src/Gui/Stylesheets/Dark-orange.qss index 2b1cd513d3..4e1432f149 100644 --- a/src/Gui/Stylesheets/Dark-orange.qss +++ b/src/Gui/Stylesheets/Dark-orange.qss @@ -1615,6 +1615,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #d0970c, stop:1 #daa116); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: #e0e0e0; + background-color: #5a5a5a; + padding: 0px; + margin: 0px; +} + /*================================================================================================== Radio button diff --git a/src/Gui/Stylesheets/Darker-blue.qss b/src/Gui/Stylesheets/Darker-blue.qss index 2367454529..31fc761cb5 100644 --- a/src/Gui/Stylesheets/Darker-blue.qss +++ b/src/Gui/Stylesheets/Darker-blue.qss @@ -1615,6 +1615,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #1b3774, stop:1 #2053c0); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: #f5f5f5; + background: #2a2a2a; + padding: 0px; + margin: 0px; +} + /*================================================================================================== QComboBox inside Task Panel content diff --git a/src/Gui/Stylesheets/Darker-green.qss b/src/Gui/Stylesheets/Darker-green.qss index a6e72ab654..004d55c3e7 100644 --- a/src/Gui/Stylesheets/Darker-green.qss +++ b/src/Gui/Stylesheets/Darker-green.qss @@ -1615,6 +1615,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #566214, stop:1 #74831d); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: #f5f5f5; + background: #2a2a2a; + padding: 0px; + margin: 0px; +} + /*================================================================================================== QComboBox inside Task Panel content diff --git a/src/Gui/Stylesheets/Darker-orange.qss b/src/Gui/Stylesheets/Darker-orange.qss index defd6a76aa..e19faeb6cc 100644 --- a/src/Gui/Stylesheets/Darker-orange.qss +++ b/src/Gui/Stylesheets/Darker-orange.qss @@ -1609,6 +1609,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #624b14, stop:1 #b28416); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: #f5f5f5; + background-color: #2a2a2a; + padding: 0px; + margin: 0px; +} + /*================================================================================================== QComboBox inside Task Panel content diff --git a/src/Gui/Stylesheets/Light-blue.qss b/src/Gui/Stylesheets/Light-blue.qss index 2136fdcee3..cbb3ee70e1 100644 --- a/src/Gui/Stylesheets/Light-blue.qss +++ b/src/Gui/Stylesheets/Light-blue.qss @@ -1612,6 +1612,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #3874f2, stop:1 #5e90fa); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: black; + background: #f5f5f5; + padding: 0px; + margin: 0px; +} + /*================================================================================================== Radio button diff --git a/src/Gui/Stylesheets/Light-green.qss b/src/Gui/Stylesheets/Light-green.qss index a384a48428..6567b974e0 100644 --- a/src/Gui/Stylesheets/Light-green.qss +++ b/src/Gui/Stylesheets/Light-green.qss @@ -1612,6 +1612,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #819c0c, stop:1 #94b30f); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: black; + background: #f5f5f5; + padding: 0px; + margin: 0px; +} + /*================================================================================================== Radio button diff --git a/src/Gui/Stylesheets/Light-orange.qss b/src/Gui/Stylesheets/Light-orange.qss index 65b02d63dd..3d3eee57f2 100644 --- a/src/Gui/Stylesheets/Light-orange.qss +++ b/src/Gui/Stylesheets/Light-orange.qss @@ -1612,6 +1612,33 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #d0970c, stop:1 #daa116); } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: black; + background: #f5f5f5; + padding: 0px; + margin: 0px; +} + /*================================================================================================== Radio button diff --git a/src/Gui/Stylesheets/ProDark.qss b/src/Gui/Stylesheets/ProDark.qss index d1b65a7f14..d29ae2c954 100644 --- a/src/Gui/Stylesheets/ProDark.qss +++ b/src/Gui/Stylesheets/ProDark.qss @@ -1624,20 +1624,6 @@ QWidget#Form QPushButton:pressed { background-color: #557BB6; } -/* Sketcher Manual Update Button */ - -QPushButton#manualUpdate { - padding: 4px; - margin: 0px; - border: 1px solid #494949; -} - -QPushButton:pressed#manualUpdate { - color: #ffffff; - border: 1px solid #3c3c3c; - background-color: #48699a; -} - /* Addon Manager */ QDialog#Dialog QPushButton { @@ -1815,6 +1801,32 @@ QSint--ActionGroup QFrame[class="content"] QToolButton:pressed { background-color: #557BB6; } +/* QToolButtons with a menu found in Sketcher task panel*/ +QSint--ActionGroup QToolButton::menu-button { + border: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + padding: 2px; + width: 16px; /* 16px width + 4px for border = 20px allocated above */ + outline: none; + background-color: transparent; +} + +QSint--ActionGroup QToolButton#settingsButton, +QSint--ActionGroup QToolButton#filterButton, +QSint--ActionGroup QToolButton#manualUpdate { + padding: 2px; + padding-right: 20px; /* make way for the popup button */ + margin: 0px; +} + +/* to give widget inside the menu same look as regular menu */ +QSint--ActionGroup QToolButton#filterButton QListWidget { + color: #f5f5f5; + background: #2a2a2a; + padding: 0px; + margin: 0px; +} /*================================================================================================== QComboBox inside Task Panel content @@ -1824,7 +1836,7 @@ QComboBox inside Task Panel content /* TODO: external border not working, in the rest of GUI works setting up Qmenu background color but inside Task Panel it doesn't... */ QSint--ActionGroup QFrame[class="content"] QMenu, QSint--ActionGroup QFrame[class="content"] QMenu::item { - background-color: #696969; + background-color: #2a2a2a; } QSint--ActionGroup QFrame[class="content"] QComboBox QAbstractItemView { diff --git a/src/Gui/ToolBarManager.cpp b/src/Gui/ToolBarManager.cpp index 91d9e7f56a..05e7e00bda 100644 --- a/src/Gui/ToolBarManager.cpp +++ b/src/Gui/ToolBarManager.cpp @@ -182,6 +182,14 @@ void ToolBarManager::setup(ToolBarItem* toolBarItems) static QPointer _ActionWidget; if (!_ActionWidget) { _ActionWidget = new QWidget(getMainWindow()); + _ActionWidget->setObjectName(QStringLiteral("_fc_action_widget_")); + /* TODO This is a temporary hack until a longterm solution + is found, thanks to @realthunder for this pointer. + Although _ActionWidget has zero size, it somehow has a + 'phantom' size without any visible content and will block the top + left tool buttons and menus of the application main window. + Therefore it is moved out of the way. */ + _ActionWidget->move(QPoint(-100,-100)); } else { for (auto action : _ActionWidget->actions()) diff --git a/src/Gui/resource.cpp b/src/Gui/resource.cpp index ac2b8ae313..8e84edf1e2 100644 --- a/src/Gui/resource.cpp +++ b/src/Gui/resource.cpp @@ -36,6 +36,7 @@ #include "DlgSettingsViewColor.h" #include "DlgGeneralImp.h" #include "DlgEditorImp.h" +#include "DlgSettingsNotificationArea.h" #include "DlgSettingsPythonConsole.h" #include "DlgSettingsMacroImp.h" #include "DlgSettingsUnitsImp.h" @@ -66,19 +67,20 @@ WidgetFactorySupplier::WidgetFactorySupplier() // ADD YOUR PREFERENCE PAGES HERE // // - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); - new PrefPageProducer( QT_TRANSLATE_NOOP("QObject","General") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","Display") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","Display") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","Display") ); - new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","Workbenches") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","General") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","Display") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","Display") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","Display") ); + new PrefPageProducer ( QT_TRANSLATE_NOOP("QObject","Workbenches") ); // ADD YOUR CUSTOMIZE PAGES HERE // diff --git a/src/Mod/AddonManager/Addon.py b/src/Mod/AddonManager/Addon.py index 9421d74624..cbe91f34d2 100644 --- a/src/Mod/AddonManager/Addon.py +++ b/src/Mod/AddonManager/Addon.py @@ -25,20 +25,23 @@ import os from urllib.parse import urlparse -from typing import Dict, Set, List +from typing import Dict, Set, List, Optional from threading import Lock from enum import IntEnum, auto -import FreeCAD - -if FreeCAD.GuiUp: - import FreeCADGui - +import addonmanager_freecad_interface as fci from addonmanager_macro import Macro import addonmanager_utilities as utils from addonmanager_utilities import construct_git_url +from addonmanager_metadata import ( + Metadata, + MetadataReader, + UrlType, + Version, + DependencyType, +) -translate = FreeCAD.Qt.translate +translate = fci.translate INTERNAL_WORKBENCHES = { "arch": "Arch", @@ -137,10 +140,10 @@ class Addon: """An exception type for dependency resolution failure.""" # The location of Addon Manager cache files: overridden by testing code - cache_directory = os.path.join(FreeCAD.getUserCachePath(), "AddonManager") + cache_directory = os.path.join(fci.DataPaths().cache_dir, "AddonManager") # The location of the Mod directory: overridden by testing code - mod_directory = os.path.join(FreeCAD.getUserAppDataDir(), "Mod") + mod_directory = fci.DataPaths().mod_dir def __init__( self, @@ -161,7 +164,8 @@ class Addon: self.tags = set() # Just a cache, loaded from Metadata self.last_updated = None - # To prevent multiple threads from running git actions on this repo at the same time + # To prevent multiple threads from running git actions on this repo at the + # same time self.git_lock = Lock() # To prevent multiple threads from accessing the status at the same time @@ -183,7 +187,7 @@ class Addon: self.metadata_url = construct_git_url(self, "package.xml") else: self.metadata_url = None - self.metadata = None + self.metadata: Optional[Metadata] = None self.icon = None # Relative path to remote icon file self.icon_file: str = "" # Absolute local path to cached icon file self.best_icon_relative_path = "" @@ -290,126 +294,82 @@ class Addon: """Read a given metadata file and set it as this object's metadata""" if os.path.exists(file): - metadata = FreeCAD.Metadata(file) + metadata = MetadataReader.from_file(file) self.set_metadata(metadata) else: - FreeCAD.Console.PrintLog(f"Internal error: {file} does not exist") + fci.Console.PrintLog(f"Internal error: {file} does not exist") - def set_metadata(self, metadata: FreeCAD.Metadata) -> None: - """Set the given metadata object as this object's metadata, updating the object's display name - and package type information to match, as well as updating any dependency information, etc. + def set_metadata(self, metadata: Metadata) -> None: + """Set the given metadata object as this object's metadata, updating the + object's display name and package type information to match, as well as + updating any dependency information, etc. """ self.metadata = metadata - self.display_name = metadata.Name + self.display_name = metadata.name self.repo_type = Addon.Kind.PACKAGE - self.description = metadata.Description - for url in metadata.Urls: - if "type" in url and url["type"] == "repository": - self.url = url["location"] - if "branch" in url: - self.branch = url["branch"] - else: - self.branch = "master" + self.description = metadata.description + for url in metadata.url: + if url.type == UrlType.repository: + self.url = url.location + self.branch = url.branch if url.branch else "master" self.extract_tags(self.metadata) self.extract_metadata_dependencies(self.metadata) - def version_is_ok(self, metadata) -> bool: - """Checks to see if the current running version of FreeCAD meets the requirements set by - the passed-in metadata parameter.""" + @staticmethod + def version_is_ok(metadata: Metadata) -> bool: + """Checks to see if the current running version of FreeCAD meets the + requirements set by the passed-in metadata parameter.""" - dep_fc_min = metadata.FreeCADMin - dep_fc_max = metadata.FreeCADMax + from_fci = list(fci.Version()) + fc_version = Version(from_list=from_fci) - fc_major = int(FreeCAD.Version()[0]) - fc_minor = int(FreeCAD.Version()[1]) + dep_fc_min = metadata.freecadmin if metadata.freecadmin else fc_version + dep_fc_max = metadata.freecadmax if metadata.freecadmax else fc_version - try: - if dep_fc_min and dep_fc_min != "0.0.0": - required_version = dep_fc_min.split(".") - if fc_major < int(required_version[0]): - return False # Major version is too low - if fc_major == int(required_version[0]): - if len(required_version) > 1 and fc_minor < int( - required_version[1] - ): - return False # Same major, and minor is too low - except ValueError: - FreeCAD.Console.PrintMessage( - f"Metadata file for {self.name} has invalid FreeCADMin version info\n" - ) + return dep_fc_min <= fc_version <= dep_fc_max - try: - if dep_fc_max and dep_fc_max != "0.0.0": - required_version = dep_fc_max.split(".") - if fc_major > int(required_version[0]): - return False # Major version is too high - if fc_major == int(required_version[0]): - if len(required_version) > 1 and fc_minor > int( - required_version[1] - ): - return False # Same major, and minor is too high - except ValueError: - FreeCAD.Console.PrintMessage( - f"Metadata file for {self.name} has invalid FreeCADMax version info\n" - ) - - return True - - def extract_metadata_dependencies(self, metadata): - """Read dependency information from a metadata object and store it in this Addon""" + def extract_metadata_dependencies(self, metadata: Metadata): + """Read dependency information from a metadata object and store it in this + Addon""" # Version check: if this piece of metadata doesn't apply to this version of # FreeCAD, just skip it. - if not self.version_is_ok(metadata): + if not Addon.version_is_ok(metadata): return - if metadata.PythonMin != "0.0.0": - split_version_string = metadata.PythonMin.split(".") - if len(split_version_string) >= 2: - try: - self.python_min_version["major"] = int(split_version_string[0]) - self.python_min_version["minor"] = int(split_version_string[1]) - FreeCAD.Console.PrintLog( - f"Package {self.name}: Requires Python " - f"{split_version_string[0]}.{split_version_string[1]} or greater\n" - ) - except ValueError: - FreeCAD.Console.PrintWarning( - f"Package {self.name}: Invalid Python version requirement specified\n" - ) + if metadata.pythonmin: + self.python_min_version["major"] = metadata.pythonmin.version_as_list[0] + self.python_min_version["minor"] = metadata.pythonmin.version_as_list[1] - for dep in metadata.Depend: - if "type" in dep: - if dep["type"] == "internal": - if dep["package"] in INTERNAL_WORKBENCHES: - self.requires.add(dep["package"]) - else: - FreeCAD.Console.PrintWarning( - translate( - "AddonsInstaller", - "{}: Unrecognized internal workbench '{}'", - ).format(self.name, dep["package"]) - ) - elif dep["type"] == "addon": - self.requires.add(dep["package"]) - elif dep["type"] == "python": - if "optional" in dep and dep["optional"]: - self.python_optional.add(dep["package"]) - else: - self.python_requires.add(dep["package"]) + for dep in metadata.depend: + if dep.dependency_type == DependencyType.internal: + if dep.package in INTERNAL_WORKBENCHES: + self.requires.add(dep.package) else: - # Automatic resolution happens later, once we have a complete list of Addons - self.requires.add(dep["package"]) + fci.Console.PrintWarning( + translate( + "AddonsInstaller", + "{}: Unrecognized internal workbench '{}'", + ).format(self.name, dep.package) + ) + elif dep.dependency_type == DependencyType.addon: + self.requires.add(dep.package) + elif dep.dependency_type == DependencyType.python: + if dep.optional: + self.python_optional.add(dep.package) + else: + self.python_requires.add(dep.package) else: - # Automatic resolution happens later, once we have a complete list of Addons - self.requires.add(dep["package"]) + # Automatic resolution happens later, once we have a complete list of + # Addons + self.requires.add(dep.package) - for dep in metadata.Conflict: - self.blocks.add(dep["package"]) + for dep in metadata.conflict: + self.blocks.add(dep.package) # Recurse - content = metadata.Content + content = metadata.content for _, value in content.items(): for item in value: self.extract_metadata_dependencies(item) @@ -420,7 +380,7 @@ class Addon: the wrong branch name.""" if self.url != url: - FreeCAD.Console.PrintWarning( + fci.Console.PrintWarning( translate( "AddonsInstaller", "Addon Developer Warning: Repository URL set in package.xml file for addon {} ({}) does not match the URL it was fetched from ({})", @@ -428,7 +388,7 @@ class Addon: + "\n" ) if self.branch != branch: - FreeCAD.Console.PrintWarning( + fci.Console.PrintWarning( translate( "AddonsInstaller", "Addon Developer Warning: Repository branch set in package.xml file for addon {} ({}) does not match the branch it was fetched from ({})", @@ -436,18 +396,18 @@ class Addon: + "\n" ) - def extract_tags(self, metadata: FreeCAD.Metadata) -> None: + def extract_tags(self, metadata: Metadata) -> None: """Read the tags from the metadata object""" # Version check: if this piece of metadata doesn't apply to this version of # FreeCAD, just skip it. - if not self.version_is_ok(metadata): + if not Addon.version_is_ok(metadata): return - for new_tag in metadata.Tag: + for new_tag in metadata.tag: self.tags.add(new_tag) - content = metadata.Content + content = metadata.content for _, value in content.items(): for item in value: self.extract_tags(item) @@ -459,15 +419,12 @@ class Addon: return True if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: - FreeCAD.Console.PrintLog( + fci.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False - content = self.metadata.Content + content = self.metadata.content if not content: - FreeCAD.Console.PrintLog( - f"Package {self.display_name} does not list any content items in its package.xml metadata file.\n" - ) return False return "workbench" in content return False @@ -479,11 +436,11 @@ class Addon: return True if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: - FreeCAD.Console.PrintLog( + fci.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False - content = self.metadata.Content + content = self.metadata.content return "macro" in content return False @@ -492,18 +449,18 @@ class Addon: if self.repo_type == Addon.Kind.PACKAGE: if self.metadata is None: - FreeCAD.Console.PrintLog( + fci.Console.PrintLog( f"Addon Manager internal error: lost metadata for package {self.name}\n" ) return False - content = self.metadata.Content + content = self.metadata.content return "preferencepack" in content return False def get_best_icon_relative_path(self) -> str: - """Get the path within the repo the addon's icon. Usually specified by top-level metadata, - but some authors omit it and specify only icons for the contents. Find the first one of - those, in such cases.""" + """Get the path within the repo the addon's icon. Usually specified by + top-level metadata, but some authors omit it and specify only icons for the + contents. Find the first one of those, in such cases.""" if self.best_icon_relative_path: return self.best_icon_relative_path @@ -511,19 +468,20 @@ class Addon: if not self.metadata: return "" - real_icon = self.metadata.Icon + real_icon = self.metadata.icon if not real_icon: - # If there is no icon set for the entire package, see if there are any workbenches, which - # are required to have icons, and grab the first one we find: - content = self.metadata.Content + # If there is no icon set for the entire package, see if there are any + # workbenches, which are required to have icons, and grab the first one + # we find: + content = self.metadata.content if "workbench" in content: wb = content["workbench"][0] - if wb.Icon: - if wb.Subdirectory: - subdir = wb.Subdirectory + if wb.icon: + if wb.subdirectory: + subdir = wb.subdirectory else: - subdir = wb.Name - real_icon = subdir + wb.Icon + subdir = wb.name + real_icon = subdir + wb.icon self.best_icon_relative_path = real_icon return self.best_icon_relative_path @@ -537,19 +495,20 @@ class Addon: if not self.metadata: return "" - real_icon = self.metadata.Icon + real_icon = self.metadata.icon if not real_icon: - # If there is no icon set for the entire package, see if there are any workbenches, which - # are required to have icons, and grab the first one we find: - content = self.metadata.Content + # If there is no icon set for the entire package, see if there are any + # workbenches, which are required to have icons, and grab the first one + # we find: + content = self.metadata.content if "workbench" in content: wb = content["workbench"][0] - if wb.Icon: - if wb.Subdirectory: - subdir = wb.Subdirectory + if wb.icon: + if wb.subdirectory: + subdir = wb.subdirectory else: - subdir = wb.Name - real_icon = subdir + wb.Icon + subdir = wb.name + real_icon = subdir + wb.icon real_icon = real_icon.replace( "/", os.path.sep @@ -581,7 +540,7 @@ class Addon: deps.python_min_version["minor"], self.python_min_version["minor"] ) else: - FreeCAD.Console.PrintWarning("Unrecognized Python version information") + fci.Console.PrintWarning("Unrecognized Python version information") for dep in self.requires: if dep in all_repos: @@ -624,7 +583,8 @@ class Addon: return os.path.exists(stopfile) def disable(self): - """Disable this addon from loading when FreeCAD starts up by creating a stopfile""" + """Disable this addon from loading when FreeCAD starts up by creating a + stopfile""" stopfile = os.path.join(self.mod_directory, self.name, "ADDON_DISABLED") with open(stopfile, "w", encoding="utf-8") as f: @@ -661,8 +621,8 @@ class MissingDependencies: repo_name_dict[r.display_name] = r if hasattr(repo, "walk_dependency_tree"): - # Sometimes the test harness doesn't provide this function, to override any dependency - # checking + # Sometimes the test harness doesn't provide this function, to override + # any dependency checking repo.walk_dependency_tree(repo_name_dict, deps) self.external_addons = [] @@ -671,8 +631,8 @@ class MissingDependencies: self.external_addons.append(dep.name) # Now check the loaded addons to see if we are missing an internal workbench: - if FreeCAD.GuiUp: - wbs = [wb.lower() for wb in FreeCADGui.listWorkbenches()] + if fci.FreeCADGui: + wbs = [wb.lower() for wb in fci.FreeCADGui.listWorkbenches()] else: wbs = [] @@ -686,7 +646,7 @@ class MissingDependencies: except ImportError: # Plot might fail for a number of reasons self.wbs.append(dep) - FreeCAD.Console.PrintLog("Failed to import Plot module") + fci.Console.PrintLog("Failed to import Plot module") else: self.wbs.append(dep) @@ -699,6 +659,13 @@ class MissingDependencies: __import__(py_dep) except ImportError: self.python_requires.append(py_dep) + except (OSError, NameError, TypeError, RuntimeError) as e: + fci.Console.PrintWarning( + translate( + "AddonsInstaller", + "Got an error when trying to import {}", + ).format(py_dep) + ":\n" + str(e) + ) self.python_optional = [] for py_dep in deps.python_optional: @@ -706,6 +673,13 @@ class MissingDependencies: __import__(py_dep) except ImportError: self.python_optional.append(py_dep) + except (OSError, NameError, TypeError, RuntimeError) as e: + fci.Console.PrintWarning( + translate( + "AddonsInstaller", + "Got an error when trying to import {}", + ).format(py_dep) + ":\n" + str(e) + ) self.wbs.sort() self.external_addons.sort() diff --git a/src/Mod/AddonManager/AddonManagerTest/app/mocks.py b/src/Mod/AddonManager/AddonManagerTest/app/mocks.py index eda4fd44e6..470a2393b4 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/mocks.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/mocks.py @@ -74,57 +74,6 @@ class MockConsole: return counter -class MockMetadata: - """Minimal implementation of a Metadata-like object.""" - - def __init__(self): - self.Name = "MockMetadata" - self.Urls = {"repository": {"location": "file://localhost/", "branch": "main"}} - self.Description = "Mock metadata object for testing" - self.Icon = None - self.Version = "1.2.3beta" - self.Content = {} - - def minimal_file_scan(self, file: Union[os.PathLike, bytes]): - """Don't use the real metadata class, but try to read in the parameters we care about - from the given metadata file (or file-like object, as the case probably is). This - allows us to test whether the data is being passed around correctly.""" - - # pylint: disable=too-many-branches - xml = None - root = None - try: - if os.path.exists(file): - xml = ElemTree.parse(file) - root = xml.getroot() - except TypeError: - pass - if xml is None: - root = ElemTree.fromstring(file) - if root is None: - raise RuntimeError("Failed to parse XML data") - - accepted_namespaces = ["", "{https://wiki.freecad.org/Package_Metadata}"] - - for ns in accepted_namespaces: - for child in root: - if child.tag == ns + "name": - self.Name = child.text - elif child.tag == ns + "description": - self.Description = child.text - elif child.tag == ns + "icon": - self.Icon = child.text - elif child.tag == ns + "url": - if "type" in child.attrib and child.attrib["type"] == "repository": - url = child.text - if "branch" in child.attrib: - branch = child.attrib["branch"] - else: - branch = "master" - self.Urls["repository"]["location"] = url - self.Urls["repository"]["branch"] = branch - - class MockAddon: """Minimal Addon class""" @@ -161,18 +110,6 @@ class MockAddon: def set_status(self, status): self.update_status = status - def set_metadata(self, metadata_like: MockMetadata): - """Set (some) of the metadata, but don't use a real Metadata object""" - self.metadata = metadata_like - if "repository" in self.metadata.Urls: - self.branch = self.metadata.Urls["repository"]["branch"] - self.url = self.metadata.Urls["repository"]["location"] - - def load_metadata_file(self, metadata_file: os.PathLike): - if os.path.exists(metadata_file): - self.metadata = MockMetadata() - self.metadata.minimal_file_scan(metadata_file) - @staticmethod def get_best_icon_relative_path(): return "" diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py b/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py index 4d6d83dc67..8ba6e8e447 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py @@ -23,7 +23,9 @@ import unittest import os -import FreeCAD +import sys + +sys.path.append("../../") from Addon import Addon, INTERNAL_WORKBENCHES from addonmanager_macro import Macro @@ -35,7 +37,7 @@ class TestAddon(unittest.TestCase): def setUp(self): self.test_dir = os.path.join( - FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data" + os.path.dirname(__file__), "..", "data" ) def test_display_name(self): @@ -55,51 +57,6 @@ class TestAddon(unittest.TestCase): self.assertEqual(addon.name, "FreeCAD") self.assertEqual(addon.display_name, "Test Workbench") - def test_metadata_loading(self): - addon = Addon( - "FreeCAD", - "https://github.com/FreeCAD/FreeCAD", - Addon.Status.NOT_INSTALLED, - "master", - ) - addon.load_metadata_file(os.path.join(self.test_dir, "good_package.xml")) - - # Generic tests: - self.assertIsNotNone(addon.metadata) - self.assertEqual(addon.metadata.Version, "1.0.1") - self.assertEqual( - addon.metadata.Description, "A package.xml file for unit testing." - ) - - maintainer_list = addon.metadata.Maintainer - self.assertEqual(len(maintainer_list), 1, "Wrong number of maintainers found") - self.assertEqual(maintainer_list[0]["name"], "FreeCAD Developer") - self.assertEqual(maintainer_list[0]["email"], "developer@freecad.org") - - license_list = addon.metadata.License - self.assertEqual(len(license_list), 1, "Wrong number of licenses found") - self.assertEqual(license_list[0]["name"], "LGPLv2.1") - self.assertEqual(license_list[0]["file"], "LICENSE") - - url_list = addon.metadata.Urls - self.assertEqual(len(url_list), 2, "Wrong number of urls found") - self.assertEqual(url_list[0]["type"], "repository") - self.assertEqual( - url_list[0]["location"], "https://github.com/chennes/FreeCAD-Package" - ) - self.assertEqual(url_list[0]["branch"], "main") - self.assertEqual(url_list[1]["type"], "readme") - self.assertEqual( - url_list[1]["location"], - "https://github.com/chennes/FreeCAD-Package/blob/main/README.md", - ) - - contents = addon.metadata.Content - self.assertEqual(len(contents), 1, "Wrong number of content catetories found") - self.assertEqual( - len(contents["workbench"]), 1, "Wrong number of workbenches found" - ) - def test_git_url_cleanup(self): base_url = "https://github.com/FreeCAD/FreeCAD" test_urls = [f" {base_url} ", f"{base_url}.git", f" {base_url}.git "] @@ -124,7 +81,7 @@ class TestAddon(unittest.TestCase): expected_tags.add("TagA") expected_tags.add("TagB") expected_tags.add("TagC") - self.assertEqual(tags, expected_tags) + self.assertEqual(expected_tags, tags) def test_contains_functions(self): diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py b/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py index aadafb0c7f..df76569a1f 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py @@ -24,21 +24,20 @@ """Contains the unit test class for addonmanager_installer.py non-GUI functionality.""" import unittest +from unittest.mock import Mock import os import shutil import tempfile -import time from zipfile import ZipFile +import sys + +sys.path.append("../../") # So the IDE can find the imports below + import FreeCAD - -from typing import Dict - from addonmanager_installer import InstallationMethod, AddonInstaller, MacroInstaller - from addonmanager_git import GitManager, initialize_git - +from addonmanager_metadata import MetadataReader from Addon import Addon - from AddonManagerTest.app.mocks import MockAddon, MockMacro @@ -100,17 +99,12 @@ class TestAddonInstaller(unittest.TestCase): AddonInstaller._validate_object(no_branch) def test_update_metadata(self): - """If a metadata file exists in the installation location, it should be loaded.""" - installer = AddonInstaller(self.mock_addon, []) - with tempfile.TemporaryDirectory() as temp_dir: - installer.installation_path = temp_dir - addon_dir = os.path.join(temp_dir, self.mock_addon.name) - os.mkdir(addon_dir) - shutil.copy( - os.path.join(self.test_data_dir, "good_package.xml"), - os.path.join(addon_dir, "package.xml"), - ) - installer._update_metadata() # Does nothing, but should not crash + """If a metadata file exists in the installation location, it should be + loaded.""" + addon = Mock() + addon.name = "MockAddon" + installer = AddonInstaller(addon, []) + installer._update_metadata() # Does nothing, but should not crash installer = AddonInstaller(self.real_addon, []) with tempfile.TemporaryDirectory() as temp_dir: @@ -122,12 +116,12 @@ class TestAddonInstaller(unittest.TestCase): os.path.join(self.test_data_dir, "good_package.xml"), os.path.join(addon_dir, "package.xml"), ) - good_metadata = FreeCAD.Metadata(os.path.join(addon_dir, "package.xml")) + good_metadata = MetadataReader.from_file(os.path.join(addon_dir, "package.xml")) installer._update_metadata() - self.assertEqual(self.real_addon.installed_version, good_metadata.Version) + self.assertEqual(self.real_addon.installed_version, good_metadata.version) def test_finalize_zip_installation_non_github(self): - """Ensure that zipfiles are correctly extracted.""" + """Ensure that zip files are correctly extracted.""" with tempfile.TemporaryDirectory() as temp_dir: test_simple_repo = os.path.join(self.test_data_dir, "test_simple_repo.zip") non_gh_mock = MockAddon() @@ -160,52 +154,66 @@ class TestAddonInstaller(unittest.TestCase): """When there is a subdirectory with the branch name in it, find it""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: - os.mkdir(os.path.join(temp_dir,f"{self.mock_addon.name}-{self.mock_addon.branch}")) + os.mkdir( + os.path.join( + temp_dir, f"{self.mock_addon.name}-{self.mock_addon.branch}" + ) + ) result = installer._code_in_branch_subdirectory(temp_dir) - self.assertTrue(result,"Failed to find ZIP subdirectory") + self.assertTrue(result, "Failed to find ZIP subdirectory") def test_code_in_branch_subdirectory_false(self): - """When there is not a subdirectory with the branch name in it, don't find one""" + """When there is not a subdirectory with the branch name in it, don't find + one""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: result = installer._code_in_branch_subdirectory(temp_dir) - self.assertFalse(result,"Found ZIP subdirectory when there was none") + self.assertFalse(result, "Found ZIP subdirectory when there was none") def test_code_in_branch_subdirectory_more_than_one(self): """When there are multiple subdirectories, never find a branch subdirectory""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: - os.mkdir(os.path.join(temp_dir,f"{self.mock_addon.name}-{self.mock_addon.branch}")) - os.mkdir(os.path.join(temp_dir,"AnotherSubdir")) + os.mkdir( + os.path.join( + temp_dir, f"{self.mock_addon.name}-{self.mock_addon.branch}" + ) + ) + os.mkdir(os.path.join(temp_dir, "AnotherSubdir")) result = installer._code_in_branch_subdirectory(temp_dir) - self.assertFalse(result,"Found ZIP subdirectory when there were multiple subdirs") + self.assertFalse( + result, "Found ZIP subdirectory when there were multiple subdirs" + ) def test_move_code_out_of_subdirectory(self): """All files are moved out and the subdirectory is deleted""" installer = AddonInstaller(self.mock_addon, []) with tempfile.TemporaryDirectory() as temp_dir: - subdir = os.path.join(temp_dir,f"{self.mock_addon.name}-{self.mock_addon.branch}") + subdir = os.path.join( + temp_dir, f"{self.mock_addon.name}-{self.mock_addon.branch}" + ) os.mkdir(subdir) - with open(os.path.join(subdir,"README.txt"),"w",encoding="utf-8") as f: + with open(os.path.join(subdir, "README.txt"), "w", encoding="utf-8") as f: f.write("# Test file for unit testing") - with open(os.path.join(subdir,"AnotherFile.txt"),"w",encoding="utf-8") as f: + with open( + os.path.join(subdir, "AnotherFile.txt"), "w", encoding="utf-8" + ) as f: f.write("# Test file for unit testing") installer._move_code_out_of_subdirectory(temp_dir) - self.assertTrue(os.path.isfile(os.path.join(temp_dir,"README.txt"))) - self.assertTrue(os.path.isfile(os.path.join(temp_dir,"AnotherFile.txt"))) + self.assertTrue(os.path.isfile(os.path.join(temp_dir, "README.txt"))) + self.assertTrue(os.path.isfile(os.path.join(temp_dir, "AnotherFile.txt"))) self.assertFalse(os.path.isdir(subdir)) - def test_install_by_git(self): - """Test using git to install. Depends on there being a local git installation: the test - is skipped if there is no local git.""" + """Test using git to install. Depends on there being a local git + installation: the test is skipped if there is no local git.""" git_manager = initialize_git() if not git_manager: self.skipTest("git not found, skipping git installer tests") return - # Our test git repo has to be in a zipfile, otherwise it cannot itself be stored in git, - # since it has a .git subdirectory. + # Our test git repo has to be in a zipfile, otherwise it cannot itself be + # stored in git, since it has a .git subdirectory. with tempfile.TemporaryDirectory() as temp_dir: git_repo_zip = os.path.join(self.test_data_dir, "test_repo.zip") with ZipFile(git_repo_zip, "r") as zip_repo: @@ -334,28 +342,32 @@ class TestAddonInstaller(unittest.TestCase): self.assertEqual(method, InstallationMethod.ZIP) def test_determine_install_method_https_known_sites_copy(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.COPY ) self.assertIsNone(method, f"Allowed copying from {site} URL") def test_determine_install_method_https_known_sites_git(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.GIT ) @@ -366,14 +378,16 @@ class TestAddonInstaller(unittest.TestCase): ) def test_determine_install_method_https_known_sites_zip(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.ZIP ) @@ -384,14 +398,16 @@ class TestAddonInstaller(unittest.TestCase): ) def test_determine_install_method_https_known_sites_any_gm(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = True for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.ANY ) @@ -402,14 +418,16 @@ class TestAddonInstaller(unittest.TestCase): ) def test_determine_install_method_https_known_sites_any_no_gm(self): - """Test which install methods are accepted for an https github URL""" + """Test which install methods are accepted for an https GitHub URL""" installer = AddonInstaller(self.mock_addon, []) installer.git_manager = None for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]: with self.subTest(site=site): - temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + temp_file = ( + f"https://{site}/dummy/dummy" # Doesn't have to actually exist! + ) method = installer._determine_install_method( temp_file, InstallationMethod.ANY ) @@ -436,7 +454,6 @@ class TestAddonInstaller(unittest.TestCase): class TestMacroInstaller(unittest.TestCase): - MODULE = "test_installer" # file name without extension def setUp(self): @@ -448,8 +465,9 @@ class TestMacroInstaller(unittest.TestCase): def test_installation(self): """Test the wrapper around the macro installer""" - # Note that this doesn't test the underlying Macro object's install function, it only - # tests whether that function is called appropriately by the MacroInstaller wrapper. + # Note that this doesn't test the underlying Macro object's install function, + # it only tests whether that function is called appropriately by the + # MacroInstaller wrapper. with tempfile.TemporaryDirectory() as temp_dir: installer = MacroInstaller(self.mock) installer.installation_path = temp_dir diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py b/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py index 280e6abf22..20960e3c6e 100644 --- a/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py @@ -21,18 +21,21 @@ # * * # *************************************************************************** -import unittest import os +import sys import tempfile -import FreeCAD - from typing import Dict +import unittest +from unittest.mock import MagicMock + +sys.path.append("../../") # So the IDE can find the + +import FreeCAD from addonmanager_macro import Macro class TestMacro(unittest.TestCase): - MODULE = "test_macro" # file name without extension def setUp(self): @@ -183,49 +186,28 @@ static char * blarg_xpm[] = { return m def test_fetch_raw_code_no_data(self): - class MockNetworkManagerNoData: - def __init__(self): - self.fetched_url = None - - def blocking_get(self, url): - self.fetched_url = url - return None - - nmNoData = MockNetworkManagerNoData() m = Macro("Unit Test Macro") - Macro.network_manager = nmNoData + Macro.blocking_get = MagicMock(return_value=None) returned_data = m._fetch_raw_code( 'rawcodeurl Totally fake' ) self.assertIsNone(returned_data) - self.assertEqual(nmNoData.fetched_url, "https://fake_url.com") + m.blocking_get.assert_called_with("https://fake_url.com") + Macro.blocking_get = None - nmNoData.fetched_url = None + def test_fetch_raw_code_no_url(self): + m = Macro("Unit Test Macro") + Macro.blocking_get = MagicMock(return_value=None) returned_data = m._fetch_raw_code("Fake pagedata with no URL at all.") self.assertIsNone(returned_data) - self.assertIsNone(nmNoData.fetched_url) - - Macro.network_manager = None + m.blocking_get.assert_not_called() + Macro.blocking_get = None def test_fetch_raw_code_with_data(self): - class MockNetworkManagerWithData: - class MockQByteArray: - def data(self): - return "Data returned to _fetch_raw_code".encode("utf-8") - - def __init__(self): - self.fetched_url = None - - def blocking_get(self, url): - self.fetched_url = url - return MockNetworkManagerWithData.MockQByteArray() - - nmWithData = MockNetworkManagerWithData() m = Macro("Unit Test Macro") - Macro.network_manager = nmWithData + Macro.blocking_get = MagicMock(return_value=b"Data returned to _fetch_raw_code") returned_data = m._fetch_raw_code( 'rawcodeurl Totally fake' ) self.assertEqual(returned_data, "Data returned to _fetch_raw_code") - - Macro.network_manager = None + Macro.blocking_get = None diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py b/src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py new file mode 100644 index 0000000000..807ed7d494 --- /dev/null +++ b/src/Mod/AddonManager/AddonManagerTest/app/test_metadata.py @@ -0,0 +1,620 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2023 FreeCAD Project Association * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD 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 * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** +import os +import sys +import tempfile +import unittest +import unittest.mock + +Mock = unittest.mock.MagicMock + +sys.path.append("../../") + + +class TestVersion(unittest.TestCase): + def setUp(self) -> None: + if "addonmanager_metadata" in sys.modules: + sys.modules.pop("addonmanager_metadata") + self.packaging_version = None + if "packaging.version" in sys.modules: + self.packaging_version = sys.modules["packaging.version"] + sys.modules.pop("packaging.version") + + def tearDown(self) -> None: + if self.packaging_version is not None: + sys.modules["packaging.version"] = self.packaging_version + + def test_init_from_string_manual(self): + import addonmanager_metadata as amm + + version = amm.Version() + version._parse_string_to_tuple = unittest.mock.MagicMock() + version._init_from_string("1.2.3beta") + self.assertTrue(version._parse_string_to_tuple.called) + + def test_init_from_list_good(self): + """Initialization from a list works for good input""" + import addonmanager_metadata as amm + + test_cases = [ + {"input": (1,), "output": [1, 0, 0, ""]}, + {"input": (1, 2), "output": [1, 2, 0, ""]}, + {"input": (1, 2, 3), "output": [1, 2, 3, ""]}, + {"input": (1, 2, 3, "b1"), "output": [1, 2, 3, "b1"]}, + ] + for test_case in test_cases: + with self.subTest(test_case=test_case): + v = amm.Version(from_list=test_case["input"]) + self.assertListEqual(test_case["output"], v.version_as_list) + + def test_parse_string_to_tuple_normal(self): + """Parsing of complete version string works for normal cases""" + import addonmanager_metadata as amm + + cases = { + "1": [1, 0, 0, ""], + "1.2": [1, 2, 0, ""], + "1.2.3": [1, 2, 3, ""], + "1.2.3beta": [1, 2, 3, "beta"], + "12_345.6_7.8pre-alpha": [12345, 67, 8, "pre-alpha"], + # The above test is mostly to point out that Python gets permits underscore + # characters in a number. + } + for inp, output in cases.items(): + with self.subTest(inp=inp, output=output): + version = amm.Version() + version._parse_string_to_tuple(inp) + self.assertListEqual(version.version_as_list, output) + + def test_parse_string_to_tuple_invalid(self): + """Parsing of invalid version string raises an exception""" + import addonmanager_metadata as amm + + cases = {"One", "1,2,3", "1-2-3", "1/2/3"} + for inp in cases: + with self.subTest(inp=inp): + with self.assertRaises(ValueError): + version = amm.Version() + version._parse_string_to_tuple(inp) + + def test_parse_final_entry_normal(self): + """Parsing of the final entry works for normal cases""" + import addonmanager_metadata as amm + + cases = { + "3beta": (3, "beta"), + "42.alpha": (42, ".alpha"), + "123.45.6": (123, ".45.6"), + "98_delta": (98, "_delta"), + "1 and some words": (1, " and some words"), + } + for inp, output in cases.items(): + with self.subTest(inp=inp, output=output): + number, text = amm.Version._parse_final_entry(inp) + self.assertEqual(number, output[0]) + self.assertEqual(text, output[1]) + + def test_parse_final_entry_invalid(self): + """Invalid input raises an exception""" + import addonmanager_metadata as amm + + cases = ["beta", "", ["a", "b"]] + for case in cases: + with self.subTest(case=case): + with self.assertRaises(ValueError): + amm.Version._parse_final_entry(case) + + def test_operators_internal(self): + """Test internal (non-package) comparison operators""" + sys.modules["packaging.version"] = None + import addonmanager_metadata as amm + + cases = self.given_comparison_cases() + for case in cases: + with self.subTest(case=case): + first = amm.Version(case[0]) + second = amm.Version(case[1]) + self.assertEqual(first < second, case[0] < case[1]) + self.assertEqual(first > second, case[0] > case[1]) + self.assertEqual(first <= second, case[0] <= case[1]) + self.assertEqual(first >= second, case[0] >= case[1]) + self.assertEqual(first == second, case[0] == case[1]) + + @staticmethod + def given_comparison_cases(): + return [ + ("0.0.0alpha", "1.0.0alpha"), + ("0.0.0alpha", "0.1.0alpha"), + ("0.0.0alpha", "0.0.1alpha"), + ("0.0.0alpha", "0.0.0beta"), + ("0.0.0alpha", "0.0.0alpha"), + ("1.0.0alpha", "0.0.0alpha"), + ("0.1.0alpha", "0.0.0alpha"), + ("0.0.1alpha", "0.0.0alpha"), + ("0.0.0beta", "0.0.0alpha"), + ] + + +class TestDependencyType(unittest.TestCase): + """Ensure that the DependencyType dataclass converts to the correct strings""" + + def setUp(self) -> None: + from addonmanager_metadata import DependencyType + + self.DependencyType = DependencyType + + def test_string_conversion_automatic(self): + self.assertEqual(str(self.DependencyType.automatic), "automatic") + + def test_string_conversion_internal(self): + self.assertEqual(str(self.DependencyType.internal), "internal") + + def test_string_conversion_addon(self): + self.assertEqual(str(self.DependencyType.addon), "addon") + + def test_string_conversion_python(self): + self.assertEqual(str(self.DependencyType.python), "python") + + +class TestUrlType(unittest.TestCase): + """Ensure that the UrlType dataclass converts to the correct strings""" + + def setUp(self) -> None: + from addonmanager_metadata import UrlType + + self.UrlType = UrlType + + def test_string_conversion_website(self): + self.assertEqual(str(self.UrlType.website), "website") + + def test_string_conversion_repository(self): + self.assertEqual(str(self.UrlType.repository), "repository") + + def test_string_conversion_bugtracker(self): + self.assertEqual(str(self.UrlType.bugtracker), "bugtracker") + + def test_string_conversion_readme(self): + self.assertEqual(str(self.UrlType.readme), "readme") + + def test_string_conversion_documentation(self): + self.assertEqual(str(self.UrlType.documentation), "documentation") + + def test_string_conversion_discussion(self): + self.assertEqual(str(self.UrlType.discussion), "discussion") + + +class TestMetadataAuxiliaryFunctions(unittest.TestCase): + + def test_get_first_supported_freecad_version_simple(self): + from addonmanager_metadata import Metadata, Version, get_first_supported_freecad_version + expected_result = Version(from_string="0.20.2beta") + metadata = self.given_metadata_with_freecadmin_set(expected_result) + first_version = get_first_supported_freecad_version(metadata) + self.assertEqual(expected_result, first_version) + + @staticmethod + def given_metadata_with_freecadmin_set(min_version): + from addonmanager_metadata import Metadata + metadata = Metadata() + metadata.freecadmin = min_version + return metadata + + def test_get_first_supported_freecad_version_with_content(self): + from addonmanager_metadata import Metadata, Version, get_first_supported_freecad_version + expected_result = Version(from_string="0.20.2beta") + metadata = self.given_metadata_with_freecadmin_in_content(expected_result) + first_version = get_first_supported_freecad_version(metadata) + self.assertEqual(expected_result, first_version) + + @staticmethod + def given_metadata_with_freecadmin_in_content(min_version): + from addonmanager_metadata import Metadata, Version + v_list = min_version.version_as_list + metadata = Metadata() + wb1 = Metadata() + wb1.freecadmin = Version(from_list=[v_list[0]+1,v_list[1],v_list[2],v_list[3]]) + wb2 = Metadata() + wb2.freecadmin = Version(from_list=[v_list[0],v_list[1]+1,v_list[2],v_list[3]]) + wb3 = Metadata() + wb3.freecadmin = Version(from_list=[v_list[0],v_list[1],v_list[2]+1,v_list[3]]) + m1 = Metadata() + m1.freecadmin = min_version + metadata.content = {"workbench":[wb1,wb2,wb3],"macro":[m1]} + return metadata + + +class TestMetadataReader(unittest.TestCase): + """Test reading metadata from XML""" + + def setUp(self) -> None: + if "xml.etree.ElementTree" in sys.modules: + sys.modules.pop("xml.etree.ElementTree") + if "MetadataReader" in sys.modules: + sys.modules.pop("MetadataReader") + + def tearDown(self) -> None: + if "xml.etree.ElementTree" in sys.modules: + sys.modules.pop("xml.etree.ElementTree") + if "MetadataReader" in sys.modules: + sys.modules.pop("MetadataReader") + + def test_from_file(self): + from addonmanager_metadata import MetadataReader + + MetadataReader.from_bytes = Mock() + with tempfile.NamedTemporaryFile(delete=False) as temp: + temp.write(b"Some data") + temp.close() + MetadataReader.from_file(temp.name) + self.assertTrue(MetadataReader.from_bytes.called) + MetadataReader.from_bytes.assert_called_once_with(b"Some data") + os.unlink(temp.name) + + @unittest.skip("Breaks other tests, needs to be fixed") + def test_from_bytes(self): + import xml.etree.ElementTree + + with unittest.mock.patch("xml.etree.ElementTree") as element_tree_mock: + from addonmanager_metadata import MetadataReader + + MetadataReader._process_element_tree = Mock() + MetadataReader.from_bytes(b"Some data") + element_tree_mock.parse.assert_called_once_with(b"Some data") + + def test_process_element_tree(self): + from addonmanager_metadata import MetadataReader + + MetadataReader._determine_namespace = Mock(return_value="") + element_tree_mock = Mock() + MetadataReader._create_node = Mock() + MetadataReader._process_element_tree(element_tree_mock) + MetadataReader._create_node.assert_called_once() + + def test_determine_namespace_found_full(self): + from addonmanager_metadata import MetadataReader + + root = Mock() + root.tag = "{https://wiki.freecad.org/Package_Metadata}package" + found_ns = MetadataReader._determine_namespace(root) + self.assertEqual(found_ns, "{https://wiki.freecad.org/Package_Metadata}") + + def test_determine_namespace_found_empty(self): + from addonmanager_metadata import MetadataReader + + root = Mock() + root.tag = "package" + found_ns = MetadataReader._determine_namespace(root) + self.assertEqual(found_ns, "") + + def test_determine_namespace_not_found(self): + from addonmanager_metadata import MetadataReader + + root = Mock() + root.find = Mock(return_value=False) + with self.assertRaises(RuntimeError): + MetadataReader._determine_namespace(root) + + def test_parse_child_element_simple_strings(self): + from addonmanager_metadata import Metadata, MetadataReader + + tags = ["name", "date", "description", "icon", "classname", "subdirectory"] + for tag in tags: + with self.subTest(tag=tag): + text = f"Test Data for {tag}" + child = self.given_mock_tree_node(tag, text) + mock_metadata = Metadata() + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(mock_metadata.__dict__[tag], text) + + def test_parse_child_element_version(self): + from addonmanager_metadata import Metadata, Version, MetadataReader + + mock_metadata = Metadata() + child = self.given_mock_tree_node("version", "1.2.3") + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(Version("1.2.3"), mock_metadata.version) + + def test_parse_child_element_lists_of_strings(self): + from addonmanager_metadata import Metadata, MetadataReader + + tags = ["file", "tag"] + for tag in tags: + with self.subTest(tag=tag): + mock_metadata = Metadata() + expected_results = [] + for i in range(10): + text = f"Test {i} for {tag}" + expected_results.append(text) + child = self.given_mock_tree_node(tag, text) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_lists_of_contacts(self): + from addonmanager_metadata import Metadata, Contact, MetadataReader + + tags = ["maintainer", "author"] + for tag in tags: + with self.subTest(tag=tag): + mock_metadata = Metadata() + expected_results = [] + for i in range(10): + text = f"Test {i} for {tag}" + email = f"Email {i} for {tag}" if i % 2 == 0 else None + expected_results.append(Contact(name=text, email=email)) + child = self.given_mock_tree_node(tag, text, {"email": email}) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_list_of_licenses(self): + from addonmanager_metadata import Metadata, License, MetadataReader + + mock_metadata = Metadata() + expected_results = [] + tag = "license" + for i in range(10): + text = f"Test {i} for {tag}" + file = f"Filename {i} for {tag}" if i % 2 == 0 else None + expected_results.append(License(name=text, file=file)) + child = self.given_mock_tree_node(tag, text, {"file": file}) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_list_of_urls(self): + from addonmanager_metadata import Metadata, Url, UrlType, MetadataReader + + mock_metadata = Metadata() + expected_results = [] + tag = "url" + for i in range(10): + text = f"Test {i} for {tag}" + url_type = UrlType(i % len(UrlType)) + type = str(url_type) + branch = "" + if type == "repository": + branch = f"Branch {i} for {tag}" + expected_results.append(Url(location=text, type=url_type, branch=branch)) + child = self.given_mock_tree_node( + tag, text, {"type": type, "branch": branch} + ) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_lists_of_dependencies(self): + from addonmanager_metadata import ( + Metadata, + Dependency, + DependencyType, + MetadataReader, + ) + + tags = ["depend", "conflict", "replace"] + attributes = { + "version_lt": "1.0.0", + "version_lte": "1.0.0", + "version_eq": "1.0.0", + "version_gte": "1.0.0", + "version_gt": "1.0.0", + "condition": "$BuildVersionMajor<1", + "optional": True, + } + + for tag in tags: + for attribute, attr_value in attributes.items(): + with self.subTest(tag=tag, attribute=attribute): + mock_metadata = Metadata() + expected_results = [] + for i in range(10): + text = f"Test {i} for {tag}" + dependency_type = DependencyType(i % len(DependencyType)) + dependency_type_str = str(dependency_type) + expected = Dependency( + package=text, dependency_type=dependency_type + ) + expected.__dict__[attribute] = attr_value + expected_results.append(expected) + child = self.given_mock_tree_node( + tag, + text, + {"type": dependency_type_str, attribute: str(attr_value)}, + ) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(len(mock_metadata.__dict__[tag]), 10) + self.assertListEqual(mock_metadata.__dict__[tag], expected_results) + + def test_parse_child_element_ignore_unknown_tag(self): + from addonmanager_metadata import Metadata, MetadataReader + + tag = "invalid_tag" + text = "Shouldn't matter" + child = self.given_mock_tree_node(tag, text) + mock_metadata = Metadata() + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertNotIn(tag, mock_metadata.__dict__) + + def test_parse_child_element_versions(self): + from addonmanager_metadata import Metadata, Version, MetadataReader + + tags = ["version", "freecadmin", "freecadmax", "pythonmin"] + for tag in tags: + with self.subTest(tag=tag): + mock_metadata = Metadata() + text = "3.4.5beta" + child = self.given_mock_tree_node(tag, text) + MetadataReader._parse_child_element("", child, mock_metadata) + self.assertEqual(mock_metadata.__dict__[tag], Version(from_string=text)) + + def given_mock_tree_node(self, tag, text, attributes=None): + class MockTreeNode: + def __init__(self): + self.tag = tag + self.text = text + self.attrib = attributes if attributes is not None else [] + + return MockTreeNode() + + def test_parse_content_valid(self): + from addonmanager_metadata import MetadataReader + + valid_content_items = ["workbench", "macro", "preferencepack"] + MetadataReader._create_node = Mock() + for content_type in valid_content_items: + with self.subTest(content_type=content_type): + tree_mock = [self.given_mock_tree_node(content_type, None)] + metadata_mock = Mock() + MetadataReader._parse_content("", metadata_mock, tree_mock) + MetadataReader._create_node.assert_called_once() + MetadataReader._create_node.reset_mock() + + def test_parse_content_invalid(self): + from addonmanager_metadata import MetadataReader + + MetadataReader._create_node = Mock() + content_item = "no_such_content_type" + tree_mock = [self.given_mock_tree_node(content_item, None)] + metadata_mock = Mock() + MetadataReader._parse_content("", metadata_mock, tree_mock) + MetadataReader._create_node.assert_not_called() + + +class TestMetadataReaderIntegration(unittest.TestCase): + """Full-up tests of the MetadataReader class (no mocking).""" + + def setUp(self) -> None: + self.test_data_dir = os.path.join(os.path.dirname(__file__), "..", "data") + remove_list = [] + for key in sys.modules: + if "addonmanager_metadata" in key: + remove_list.append(key) + for key in remove_list: + print(f"Removing {key}") + sys.modules.pop(key) + + def test_loading_simple_metadata_file(self): + from addonmanager_metadata import ( + Contact, + Dependency, + License, + MetadataReader, + Url, + UrlType, + Version, + ) + + filename = os.path.join(self.test_data_dir, "good_package.xml") + metadata = MetadataReader.from_file(filename) + self.assertEqual("Test Workbench", metadata.name) + self.assertEqual("A package.xml file for unit testing.", metadata.description) + self.assertEqual(Version("1.0.1"), metadata.version) + self.assertEqual("2022-01-07", metadata.date) + self.assertEqual("Resources/icons/PackageIcon.svg", metadata.icon) + self.assertListEqual( + [License(name="LGPLv2.1", file="LICENSE")], metadata.license + ) + self.assertListEqual( + [Contact(name="FreeCAD Developer", email="developer@freecad.org")], + metadata.maintainer, + ) + self.assertListEqual( + [ + Url( + location="https://github.com/chennes/FreeCAD-Package", + type=UrlType.repository, + branch="main", + ), + Url( + location="https://github.com/chennes/FreeCAD-Package/blob/main/README.md", + type=UrlType.readme, + ), + ], + metadata.url, + ) + self.assertListEqual(["Tag0", "Tag1"], metadata.tag) + self.assertIn("workbench", metadata.content) + self.assertEqual(len(metadata.content["workbench"]), 1) + wb_metadata = metadata.content["workbench"][0] + self.assertEqual("MyWorkbench", wb_metadata.classname) + self.assertEqual("./", wb_metadata.subdirectory) + self.assertListEqual(["TagA", "TagB", "TagC"], wb_metadata.tag) + + def test_multiple_workbenches(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "workbench_only.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("workbench", metadata.content) + self.assertEqual(len(metadata.content["workbench"]), 3) + expected_wb_classnames = [ + "MyFirstWorkbench", + "MySecondWorkbench", + "MyThirdWorkbench", + ] + for wb in metadata.content["workbench"]: + self.assertIn(wb.classname, expected_wb_classnames) + expected_wb_classnames.remove(wb.classname) + self.assertEqual(len(expected_wb_classnames), 0) + + def test_multiple_macros(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "macro_only.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("macro", metadata.content) + self.assertEqual(len(metadata.content["macro"]), 2) + expected_wb_files = ["MyMacro.FCStd", "MyOtherMacro.FCStd"] + for wb in metadata.content["macro"]: + self.assertIn(wb.file[0], expected_wb_files) + expected_wb_files.remove(wb.file[0]) + self.assertEqual(len(expected_wb_files), 0) + + def test_multiple_preference_packs(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "prefpack_only.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("preferencepack", metadata.content) + self.assertEqual(len(metadata.content["preferencepack"]), 3) + expected_packs = ["MyFirstPack", "MySecondPack", "MyThirdPack"] + for wb in metadata.content["preferencepack"]: + self.assertIn(wb.name, expected_packs) + expected_packs.remove(wb.name) + self.assertEqual(len(expected_packs), 0) + + def test_content_combination(self): + from addonmanager_metadata import MetadataReader + + filename = os.path.join(self.test_data_dir, "combination.xml") + metadata = MetadataReader.from_file(filename) + self.assertIn("preferencepack", metadata.content) + self.assertEqual(len(metadata.content["preferencepack"]), 1) + self.assertIn("macro", metadata.content) + self.assertEqual(len(metadata.content["macro"]), 1) + self.assertIn("workbench", metadata.content) + self.assertEqual(len(metadata.content["workbench"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index d4c2b856e1..2d9b3e3376 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -25,6 +25,7 @@ SET(AddonManager_SRCS addonmanager_installer_gui.py addonmanager_macro.py addonmanager_macro_parser.py + addonmanager_metadata.py addonmanager_update_all_gui.py addonmanager_uninstaller.py addonmanager_uninstaller_gui.py @@ -89,6 +90,7 @@ SET(AddonManagerTestsApp_SRCS AddonManagerTest/app/test_installer.py AddonManagerTest/app/test_macro.py AddonManagerTest/app/test_macro_parser.py + AddonManagerTest/app/test_metadata.py AddonManagerTest/app/test_utilities.py AddonManagerTest/app/test_uninstaller.py ) diff --git a/src/Mod/AddonManager/TestAddonManagerApp.py b/src/Mod/AddonManager/TestAddonManagerApp.py index c5356fa0fe..2d4ce76cdb 100644 --- a/src/Mod/AddonManager/TestAddonManagerApp.py +++ b/src/Mod/AddonManager/TestAddonManagerApp.py @@ -52,6 +52,14 @@ from AddonManagerTest.app.test_freecad_interface import ( TestParameters as AddonManagerTestParameters, TestDataPaths as AddonManagerTestDataPaths, ) +from AddonManagerTest.app.test_metadata import ( + TestDependencyType as AddonManagerTestDependencyType, + TestMetadataReader as AddonManagerTestMetadataReader, + TestMetadataReaderIntegration as AddonManagerTestMetadataReaderIntegration, + TestUrlType as AddonManagerTestUrlType, + TestVersion as AddonManagerTestVersion, + TestMetadataAuxiliaryFunctions as AddonManagerTestMetadataAuxiliaryFunctions +) class TestListTerminator: @@ -76,6 +84,12 @@ loaded_gui_tests = [ AddonManagerTestConsole, AddonManagerTestParameters, AddonManagerTestDataPaths, + AddonManagerTestDependencyType, + AddonManagerTestMetadataReader, + AddonManagerTestMetadataReaderIntegration, + AddonManagerTestUrlType, + AddonManagerTestVersion, + AddonManagerTestMetadataAuxiliaryFunctions, TestListTerminator # Needed to prevent the last test from running twice ] for test in loaded_gui_tests: diff --git a/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py b/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py index 3f000d7643..25f2bd35af 100644 --- a/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py +++ b/src/Mod/AddonManager/addonmanager_devmode_metadata_checker.py @@ -28,12 +28,13 @@ from typing import List import FreeCAD from Addon import Addon +from addonmanager_metadata import Metadata import NetworkManager class MetadataValidators: - """A collection of tools for validating various pieces of metadata. Prints validation - information to the console.""" + """A collection of tools for validating various pieces of metadata. Prints + validation information to the console.""" def validate_all(self, repos): """Developer tool: check all repos for validity and print report""" @@ -64,9 +65,9 @@ class MetadataValidators: if addon.metadata is None: return - # The package.xml standard has some required elements that the basic XML reader is not - # actually checking for. In developer mode, actually make sure that all the rules are - # being followed for each element. + # The package.xml standard has some required elements that the basic XML + # reader is not actually checking for. In developer mode, actually make sure + # that all the rules are being followed for each element. errors = [] @@ -83,15 +84,15 @@ class MetadataValidators: def validate_content(self, addon: Addon) -> List[str]: """Validate the Content items for this Addon""" errors = [] - contents = addon.metadata.Content + contents = addon.metadata.content missing_icon = True - if addon.metadata.Icon and len(addon.metadata.Icon) > 0: + if addon.metadata.icon and len(addon.metadata.icon) > 0: missing_icon = False else: if "workbench" in contents: wb = contents["workbench"][0] - if wb.Icon: + if wb.icon: missing_icon = False if missing_icon: errors.append("No element found, or element is invalid") @@ -106,38 +107,37 @@ class MetadataValidators: return errors - def validate_top_level(self, addon) -> List[str]: + def validate_top_level(self, addon:Addon) -> List[str]: """Check for the presence of the required top-level elements""" errors = [] - if not addon.metadata.Name or len(addon.metadata.Name) == 0: + if not addon.metadata.name or len(addon.metadata.name) == 0: errors.append( "No top-level element found, or element is empty" ) - if not addon.metadata.Version or addon.metadata.Version == "0.0.0": + if not addon.metadata.version: errors.append( "No top-level element found, or element is invalid" ) - # if not addon.metadata.Date or len(addon.metadata.Date) == 0: - # errors.append(f"No top-level element found, or element is invalid") - if not addon.metadata.Description or len(addon.metadata.Description) == 0: + if not addon.metadata.description or len(addon.metadata.description) == 0: errors.append( - "No top-level element found, or element is invalid" + "No top-level element found, or element " + "is invalid" ) - maintainers = addon.metadata.Maintainer + maintainers = addon.metadata.maintainer if len(maintainers) == 0: errors.append("No top-level found, at least one is required") for maintainer in maintainers: - if len(maintainer["email"]) == 0: + if len(maintainer.email) == 0: errors.append( - f"No email address specified for maintainer '{maintainer['name']}'" + f"No email address specified for maintainer '{maintainer.name}'" ) - licenses = addon.metadata.License + licenses = addon.metadata.license if len(licenses) == 0: errors.append("No top-level found, at least one is required") - urls = addon.metadata.Urls + urls = addon.metadata.url errors.extend(self.validate_urls(urls)) return errors @@ -185,17 +185,17 @@ class MetadataValidators: return errors @staticmethod - def validate_workbench_metadata(workbench) -> List[str]: + def validate_workbench_metadata(workbench:Metadata) -> List[str]: """Validate the required element(s) for a workbench""" errors = [] - if not workbench.Classname or len(workbench.Classname) == 0: + if not workbench.classname or len(workbench.classname) == 0: errors.append("No specified for workbench") return errors @staticmethod - def validate_preference_pack_metadata(pack) -> List[str]: + def validate_preference_pack_metadata(pack:Metadata) -> List[str]: """Validate the required element(s) for a preference pack""" errors = [] - if not pack.Name or len(pack.Name) == 0: + if not pack.name or len(pack.name) == 0: errors.append("No specified for preference pack") return errors diff --git a/src/Mod/AddonManager/addonmanager_freecad_interface.py b/src/Mod/AddonManager/addonmanager_freecad_interface.py index 5fc4ca041a..29d1d15962 100644 --- a/src/Mod/AddonManager/addonmanager_freecad_interface.py +++ b/src/Mod/AddonManager/addonmanager_freecad_interface.py @@ -47,8 +47,14 @@ try: getUserCachePath = FreeCAD.getUserCachePath translate = FreeCAD.Qt.translate + if FreeCAD.GuiUp: + import FreeCADGui + else: + FreeCADGui = None + except ImportError: FreeCAD = None + FreeCADGui = None getUserAppDataDir = None getUserCachePath = None getUserMacroDir = None @@ -57,7 +63,7 @@ except ImportError: return string def Version(): - return 1, 0, 0 + return 0, 21, 0, "dev" class ConsoleReplacement: """If FreeCAD's Console is not available, create a replacement by redirecting FreeCAD @@ -140,6 +146,7 @@ class DataPaths: mod_dir = None macro_dir = None cache_dir = None + home_dir = None reference_count = 0 @@ -151,6 +158,8 @@ class DataPaths: self.cache_dir = getUserCachePath() if self.macro_dir is None: self.macro_dir = getUserMacroDir(True) + if self.home_dir is None: + self.home_dir = FreeCAD.getHomePath() else: self.reference_count += 1 if self.mod_dir is None: @@ -159,6 +168,8 @@ class DataPaths: self.cache_dir = tempfile.mkdtemp() if self.macro_dir is None: self.macro_dir = tempfile.mkdtemp() + if self.home_dir is None: + self.home_dir = os.path.join(os.path.dirname(__file__), "..", "..") def __del__(self): self.reference_count -= 1 diff --git a/src/Mod/AddonManager/addonmanager_installer.py b/src/Mod/AddonManager/addonmanager_installer.py index 9b98f4eda1..18c493344b 100644 --- a/src/Mod/AddonManager/addonmanager_installer.py +++ b/src/Mod/AddonManager/addonmanager_installer.py @@ -194,7 +194,7 @@ class AddonInstaller(QtCore.QObject): FreeCAD.Console.PrintLog( "Overriding local ALLOWED_PYTHON_PACKAGES.txt with newer remote version\n" ) - p = p.data().decode("utf8") + p = p.decode("utf8") lines = p.split("\n") cls.allowed_packages.clear() # Unset the locally-defined list for line in lines: @@ -407,7 +407,7 @@ class AddonInstaller(QtCore.QObject): if hasattr(self.addon_to_install, "metadata") and os.path.isfile(package_xml): self.addon_to_install.load_metadata_file(package_xml) self.addon_to_install.installed_version = ( - self.addon_to_install.metadata.Version + self.addon_to_install.metadata.version ) self.addon_to_install.updated_timestamp = os.path.getmtime(package_xml) diff --git a/src/Mod/AddonManager/addonmanager_macro.py b/src/Mod/AddonManager/addonmanager_macro.py index 34595cb921..f1308730c7 100644 --- a/src/Mod/AddonManager/addonmanager_macro.py +++ b/src/Mod/AddonManager/addonmanager_macro.py @@ -31,11 +31,10 @@ import codecs import shutil from html import unescape from typing import Dict, Tuple, List, Union, Optional - -import NetworkManager - +import urllib.parse from addonmanager_macro_parser import MacroParser +import addonmanager_utilities as utils import addonmanager_freecad_interface as fci @@ -50,10 +49,11 @@ translate = fci.translate class Macro: - """This class provides a unified way to handle macros coming from different sources""" + """This class provides a unified way to handle macros coming from different + sources""" # Use a stored class variable for this so that we can override it during testing - network_manager = None + blocking_get = None # pylint: disable=too-many-instance-attributes def __init__(self, name): @@ -76,14 +76,16 @@ class Macro: self.other_files = [] self.parsed = False self._console = fci.Console + if Macro.blocking_get is None: + Macro.blocking_get = utils.blocking_get def __eq__(self, other): return self.filename == other.filename @classmethod def from_cache(cls, cache_dict: Dict): - """Use data from the cache dictionary to create a new macro, returning a reference - to it.""" + """Use data from the cache dictionary to create a new macro, returning a + reference to it.""" instance = Macro(cache_dict["name"]) for key, value in cache_dict.items(): instance.__dict__[key] = value @@ -97,14 +99,6 @@ class Macro: cache_dict[key] = value return cache_dict - @classmethod - def _get_network_manager(cls): - if cls.network_manager is None: - # Make sure we're initialized: - NetworkManager.InitializeNetworkManager() - cls.network_manager = NetworkManager.AM_NETWORK_MANAGER - return cls.network_manager - @property def filename(self): """The filename of this macro""" @@ -113,9 +107,10 @@ class Macro: return (self.name + ".FCMacro").replace(" ", "_") def is_installed(self): - """Returns True if this macro is currently installed (that is, if it exists in the - user macro directory), or False if it is not. Both the exact filename, as well as - the filename prefixed with "Macro", are considered an installation of this macro. + """Returns True if this macro is currently installed (that is, if it exists + in the user macro directory), or False if it is not. Both the exact filename, + as well as the filename prefixed with "Macro", are considered an installation + of this macro. """ if self.on_git and not self.src_filename: return False @@ -140,12 +135,12 @@ class Macro: self.parsed = True def fill_details_from_wiki(self, url): - """For a given URL, download its data and attempt to get the macro's metadata out of - it. If the macro's code is hosted elsewhere, as specified by a "rawcodeurl" found on - the wiki page, that code is downloaded and used as the source.""" + """For a given URL, download its data and attempt to get the macro's metadata + out of it. If the macro's code is hosted elsewhere, as specified by a + "rawcodeurl" found on the wiki page, that code is downloaded and used as the + source.""" code = "" - nm = Macro._get_network_manager() - p = nm.blocking_get(url) + p = Macro.blocking_get(url) if not p: self._console.PrintWarning( translate( @@ -155,7 +150,7 @@ class Macro: + "\n" ) return - p = p.data().decode("utf8") + p = p.decode("utf8") # check if the macro page has its code hosted elsewhere, download if # needed if "rawcodeurl" in p: @@ -207,8 +202,7 @@ class Macro: self.raw_code_url = re.findall('rawcodeurl.*?href="(http.*?)">', page_data) if self.raw_code_url: self.raw_code_url = self.raw_code_url[0] - nm = Macro._get_network_manager() - u2 = nm.blocking_get(self.raw_code_url) + u2 = Macro.blocking_get(self.raw_code_url) if not u2: self._console.PrintWarning( translate( @@ -218,7 +212,7 @@ class Macro: + "\n" ) return None - code = u2.data().decode("utf8") + code = u2.decode("utf8") return code @staticmethod @@ -238,30 +232,30 @@ class Macro: copy, potentially updating the internal icon location to that local storage.""" if self.icon.startswith("http://") or self.icon.startswith("https://"): self._console.PrintLog(f"Attempting to fetch macro icon from {self.icon}\n") - nm = Macro._get_network_manager() - p = nm.blocking_get(self.icon) + parsed_url = urllib.parse.urlparse(self.icon) + p = Macro.blocking_get(self.icon) if p: cache_path = fci.DataPaths().cache_dir am_path = os.path.join(cache_path, "AddonManager", "MacroIcons") os.makedirs(am_path, exist_ok=True) - _, _, filename = self.icon.rpartition("/") + _, _, filename = parsed_url.path.rpartition("/") base, _, extension = filename.rpartition(".") if base.lower().startswith("file:"): - # pylint: disable=line-too-long self._console.PrintMessage( - f"Cannot use specified icon for {self.name}, {self.icon} is not a direct download link\n" + f"Cannot use specified icon for {self.name}, {self.icon} " + "is not a direct download link\n" ) self.icon = "" else: constructed_name = os.path.join(am_path, base + "." + extension) with open(constructed_name, "wb") as f: - f.write(p.data()) + f.write(p) self.icon_source = self.icon self.icon = constructed_name else: - # pylint: disable=line-too-long self._console.PrintLog( - f"MACRO DEVELOPER WARNING: failed to download icon from {self.icon} for macro {self.name}\n" + f"MACRO DEVELOPER WARNING: failed to download icon from {self.icon}" + f" for macro {self.name}\n" ) self.icon = "" @@ -364,8 +358,7 @@ class Macro: if self.raw_code_url: fetch_url = self.raw_code_url.rsplit("/", 1)[0] + "/" + other_file self._console.PrintLog(f"Attempting to fetch {fetch_url}...\n") - nm = Macro._get_network_manager() - p = nm.blocking_get(fetch_url) + p = Macro.blocking_get(fetch_url) if p: with open(dst_file, "wb") as f: f.write(p) @@ -381,19 +374,21 @@ class Macro: warnings.append( translate( "AddonsInstaller", - "Could not locate macro-specified file {} (should have been at {})", + "Could not locate macro-specified file {} (expected at {})", ).format(other_file, src_file) ) def parse_wiki_page_for_icon(self, page_data: str) -> None: - """Attempt to find a url for the icon in the wiki page. Sets self.icon if found.""" + """Attempt to find a url for the icon in the wiki page. Sets self.icon if + found.""" # Method 1: the text "toolbar icon" appears on the page, and provides a direct # link to an icon # pylint: disable=line-too-long # Try to get an icon from the wiki page itself: - # ToolBar Icon + # ToolBar Icon icon_regex = re.compile(r'.*href="(.*?)">ToolBar Icon', re.IGNORECASE) wiki_icon = "" if "ToolBar Icon" in page_data: @@ -416,10 +411,9 @@ class Macro: self._console.PrintLog( f"Found a File: link for macro {self.name} -- {wiki_icon}\n" ) - nm = Macro._get_network_manager() - p = nm.blocking_get(wiki_icon) + p = Macro.blocking_get(wiki_icon) if p: - p = p.data().decode("utf8") + p = p.decode("utf8") f = io.StringIO(p) lines = f.readlines() trigger = False @@ -435,7 +429,8 @@ class Macro: #