diff --git a/python/PyQt6/core/auto_additions/qgis.py b/python/PyQt6/core/auto_additions/qgis.py index b6dd28e7a404..f0cc289b4f7e 100644 --- a/python/PyQt6/core/auto_additions/qgis.py +++ b/python/PyQt6/core/auto_additions/qgis.py @@ -2191,7 +2191,8 @@ # monkey patching scoped based enum Qgis.AnnotationItemFlag.ScaleDependentBoundingBox.__doc__ = "Item's bounding box will vary depending on map scale" Qgis.AnnotationItemFlag.SupportsReferenceScale.__doc__ = "Item supports reference scale based rendering (since QGIS 3.40)" -Qgis.AnnotationItemFlag.__doc__ = "Flags for annotation items.\n\n.. versionadded:: 3.22\n\n" + '* ``ScaleDependentBoundingBox``: ' + Qgis.AnnotationItemFlag.ScaleDependentBoundingBox.__doc__ + '\n' + '* ``SupportsReferenceScale``: ' + Qgis.AnnotationItemFlag.SupportsReferenceScale.__doc__ +Qgis.AnnotationItemFlag.SupportsCallouts.__doc__ = "Item supports callouts (since QGIS 3.40)" +Qgis.AnnotationItemFlag.__doc__ = "Flags for annotation items.\n\n.. versionadded:: 3.22\n\n" + '* ``ScaleDependentBoundingBox``: ' + Qgis.AnnotationItemFlag.ScaleDependentBoundingBox.__doc__ + '\n' + '* ``SupportsReferenceScale``: ' + Qgis.AnnotationItemFlag.SupportsReferenceScale.__doc__ + '\n' + '* ``SupportsCallouts``: ' + Qgis.AnnotationItemFlag.SupportsCallouts.__doc__ # -- Qgis.AnnotationItemFlags = lambda flags=0: Qgis.AnnotationItemFlag(flags) Qgis.AnnotationItemFlag.baseClass = Qgis @@ -2213,7 +2214,8 @@ AnnotationItemGuiFlags = Qgis # dirty hack since SIP seems to introduce the flags in module # monkey patching scoped based enum Qgis.AnnotationItemNodeType.VertexHandle.__doc__ = "Node is a handle for manipulating vertices" -Qgis.AnnotationItemNodeType.__doc__ = "Annotation item node types.\n\n.. versionadded:: 3.22\n\n" + '* ``VertexHandle``: ' + Qgis.AnnotationItemNodeType.VertexHandle.__doc__ +Qgis.AnnotationItemNodeType.CalloutHandle.__doc__ = "Node is a handle for manipulating callouts (since QGIS 3.40)" +Qgis.AnnotationItemNodeType.__doc__ = "Annotation item node types.\n\n.. versionadded:: 3.22\n\n" + '* ``VertexHandle``: ' + Qgis.AnnotationItemNodeType.VertexHandle.__doc__ + '\n' + '* ``CalloutHandle``: ' + Qgis.AnnotationItemNodeType.CalloutHandle.__doc__ # -- Qgis.AnnotationItemNodeType.baseClass = Qgis # monkey patching scoped based enum diff --git a/python/PyQt6/core/auto_generated/annotations/qgsannotationitem.sip.in b/python/PyQt6/core/auto_generated/annotations/qgsannotationitem.sip.in index 61e5d670ab41..f4de13f51289 100644 --- a/python/PyQt6/core/auto_generated/annotations/qgsannotationitem.sip.in +++ b/python/PyQt6/core/auto_generated/annotations/qgsannotationitem.sip.in @@ -254,6 +254,76 @@ exactly 2mm thick when a map is rendered at 1:1000, or 1mm thick when rendered a .. seealso:: :py:func:`symbologyReferenceScale` .. seealso:: :py:func:`setUseSymbologyReferenceScale` +%End + + QgsCallout *callout() const; +%Docstring +Returns the item's callout renderer, responsible for drawing item callouts. + +Ownership is not transferred. + +By default items do not have a callout, and it is necessary to be explicitly set +a callout style (via :py:func:`~QgsAnnotationItem.setCallout` ) and set the callout anchor geometry (via set +:py:func:`~QgsAnnotationItem.setCalloutAnchor` ). + +.. note:: + + Callouts are only supported by items which return :py:class:`Qgis`.AnnotationItemFlag.SupportsCallouts from :py:func:`~QgsAnnotationItem.flags`. + +.. seealso:: :py:func:`setCallout` + +.. seealso:: :py:func:`calloutAnchor` + +.. versionadded:: 3.40 +%End + + void setCallout( QgsCallout *callout /Transfer/ ); +%Docstring +Sets the item's ``callout`` renderer, responsible for drawing item callouts. + +Ownership of ``callout`` is transferred to the item. + +.. note:: + + Callouts are only supported by items which return :py:class:`Qgis`.AnnotationItemFlag.SupportsCallouts from :py:func:`~QgsAnnotationItem.flags`. + +.. seealso:: :py:func:`callout` + +.. seealso:: :py:func:`setCalloutAnchor` + +.. versionadded:: 3.40 +%End + + QgsGeometry calloutAnchor() const; +%Docstring +Returns the callout's anchor geometry. + +The anchor dictates the geometry which the option item :py:func:`~QgsAnnotationItem.callout` should connect to. Depending on the +callout subclass and anchor geometry type, the actual shape of the rendered callout may vary. + +The callout anchor geometry is in the parent layer's coordinate reference system. + +.. seealso:: :py:func:`callout` + +.. seealso:: :py:func:`setCalloutAnchor` + +.. versionadded:: 3.40 +%End + + void setCalloutAnchor( const QgsGeometry &anchor ); +%Docstring +Sets the callout's ``anchor`` geometry. + +The anchor dictates the geometry which the option item :py:func:`~QgsAnnotationItem.callout` should connect to. Depending on the +callout subclass and anchor geometry type, the actual shape of the rendered callout may vary. + +The callout ``anchor`` geometry must be specified in the parent layer's coordinate reference system. + +.. seealso:: :py:func:`setCallout` + +.. seealso:: :py:func:`calloutAnchor` + +.. versionadded:: 3.40 %End protected: @@ -281,6 +351,15 @@ Reads common properties from the base class from the given DOM ``element``. .. seealso:: :py:func:`readXml` .. versionadded:: 3.22 +%End + + void renderCallout( QgsRenderContext &context, const QRectF &rect, double angle, QgsCallout::QgsCalloutContext &calloutContext, QgsFeedback *feedback ); +%Docstring +Renders the item's callout. + +The item must have valid :py:func:`~QgsAnnotationItem.callout` set. + +.. versionadded:: 3.40 %End private: diff --git a/python/PyQt6/core/auto_generated/qgis.sip.in b/python/PyQt6/core/auto_generated/qgis.sip.in index 421614378f2d..d1c16adb9427 100644 --- a/python/PyQt6/core/auto_generated/qgis.sip.in +++ b/python/PyQt6/core/auto_generated/qgis.sip.in @@ -1281,6 +1281,7 @@ The development version { ScaleDependentBoundingBox, SupportsReferenceScale, + SupportsCallouts, }; typedef QFlags AnnotationItemFlags; @@ -1301,6 +1302,7 @@ The development version enum class AnnotationItemNodeType /BaseType=IntEnum/ { VertexHandle, + CalloutHandle, }; enum class AnnotationItemEditOperationResult /BaseType=IntEnum/ diff --git a/python/PyQt6/gui/auto_generated/annotations/qgsannotationitemcommonpropertieswidget.sip.in b/python/PyQt6/gui/auto_generated/annotations/qgsannotationitemcommonpropertieswidget.sip.in index 9cd44ce0dc03..f8fd5a94638c 100644 --- a/python/PyQt6/gui/auto_generated/annotations/qgsannotationitemcommonpropertieswidget.sip.in +++ b/python/PyQt6/gui/auto_generated/annotations/qgsannotationitemcommonpropertieswidget.sip.in @@ -27,6 +27,7 @@ A widget for configuring common properties for :py:class:`QgsAnnotationItems` %Docstring Constructor for QgsAnnotationItemCommonPropertiesWidget. %End + ~QgsAnnotationItemCommonPropertiesWidget(); void setItem( QgsAnnotationItem *item ); %Docstring diff --git a/python/PyQt6/gui/auto_generated/qgsrubberband.sip.in b/python/PyQt6/gui/auto_generated/qgsrubberband.sip.in index 8c53b9063b59..6f44e5dfafaa 100644 --- a/python/PyQt6/gui/auto_generated/qgsrubberband.sip.in +++ b/python/PyQt6/gui/auto_generated/qgsrubberband.sip.in @@ -121,7 +121,7 @@ Set to an invalid color to avoid drawing the secondary stroke. Returns the current secondary stroke color. %End - void setWidth( int width ); + void setWidth( double width ); %Docstring Sets the width of the line. Stroke width for polygon. @@ -157,12 +157,12 @@ Calling this function automatically calls setIcon(ICON_SVG) Returns the current icon type to highlight point geometries. %End - void setIconSize( int iconSize ); + void setIconSize( double iconSize ); %Docstring Sets the size of the point icons %End - int iconSize() const; + double iconSize() const; %Docstring Returns the current icon size of the point icons. %End diff --git a/python/core/auto_additions/qgis.py b/python/core/auto_additions/qgis.py index 6c5c0ab06785..28c521be25ff 100644 --- a/python/core/auto_additions/qgis.py +++ b/python/core/auto_additions/qgis.py @@ -2151,7 +2151,8 @@ # monkey patching scoped based enum Qgis.AnnotationItemFlag.ScaleDependentBoundingBox.__doc__ = "Item's bounding box will vary depending on map scale" Qgis.AnnotationItemFlag.SupportsReferenceScale.__doc__ = "Item supports reference scale based rendering (since QGIS 3.40)" -Qgis.AnnotationItemFlag.__doc__ = "Flags for annotation items.\n\n.. versionadded:: 3.22\n\n" + '* ``ScaleDependentBoundingBox``: ' + Qgis.AnnotationItemFlag.ScaleDependentBoundingBox.__doc__ + '\n' + '* ``SupportsReferenceScale``: ' + Qgis.AnnotationItemFlag.SupportsReferenceScale.__doc__ +Qgis.AnnotationItemFlag.SupportsCallouts.__doc__ = "Item supports callouts (since QGIS 3.40)" +Qgis.AnnotationItemFlag.__doc__ = "Flags for annotation items.\n\n.. versionadded:: 3.22\n\n" + '* ``ScaleDependentBoundingBox``: ' + Qgis.AnnotationItemFlag.ScaleDependentBoundingBox.__doc__ + '\n' + '* ``SupportsReferenceScale``: ' + Qgis.AnnotationItemFlag.SupportsReferenceScale.__doc__ + '\n' + '* ``SupportsCallouts``: ' + Qgis.AnnotationItemFlag.SupportsCallouts.__doc__ # -- Qgis.AnnotationItemFlag.baseClass = Qgis Qgis.AnnotationItemFlags.baseClass = Qgis @@ -2171,7 +2172,8 @@ AnnotationItemGuiFlags = Qgis # dirty hack since SIP seems to introduce the flags in module # monkey patching scoped based enum Qgis.AnnotationItemNodeType.VertexHandle.__doc__ = "Node is a handle for manipulating vertices" -Qgis.AnnotationItemNodeType.__doc__ = "Annotation item node types.\n\n.. versionadded:: 3.22\n\n" + '* ``VertexHandle``: ' + Qgis.AnnotationItemNodeType.VertexHandle.__doc__ +Qgis.AnnotationItemNodeType.CalloutHandle.__doc__ = "Node is a handle for manipulating callouts (since QGIS 3.40)" +Qgis.AnnotationItemNodeType.__doc__ = "Annotation item node types.\n\n.. versionadded:: 3.22\n\n" + '* ``VertexHandle``: ' + Qgis.AnnotationItemNodeType.VertexHandle.__doc__ + '\n' + '* ``CalloutHandle``: ' + Qgis.AnnotationItemNodeType.CalloutHandle.__doc__ # -- Qgis.AnnotationItemNodeType.baseClass = Qgis # monkey patching scoped based enum diff --git a/python/core/auto_generated/annotations/qgsannotationitem.sip.in b/python/core/auto_generated/annotations/qgsannotationitem.sip.in index 61e5d670ab41..f4de13f51289 100644 --- a/python/core/auto_generated/annotations/qgsannotationitem.sip.in +++ b/python/core/auto_generated/annotations/qgsannotationitem.sip.in @@ -254,6 +254,76 @@ exactly 2mm thick when a map is rendered at 1:1000, or 1mm thick when rendered a .. seealso:: :py:func:`symbologyReferenceScale` .. seealso:: :py:func:`setUseSymbologyReferenceScale` +%End + + QgsCallout *callout() const; +%Docstring +Returns the item's callout renderer, responsible for drawing item callouts. + +Ownership is not transferred. + +By default items do not have a callout, and it is necessary to be explicitly set +a callout style (via :py:func:`~QgsAnnotationItem.setCallout` ) and set the callout anchor geometry (via set +:py:func:`~QgsAnnotationItem.setCalloutAnchor` ). + +.. note:: + + Callouts are only supported by items which return :py:class:`Qgis`.AnnotationItemFlag.SupportsCallouts from :py:func:`~QgsAnnotationItem.flags`. + +.. seealso:: :py:func:`setCallout` + +.. seealso:: :py:func:`calloutAnchor` + +.. versionadded:: 3.40 +%End + + void setCallout( QgsCallout *callout /Transfer/ ); +%Docstring +Sets the item's ``callout`` renderer, responsible for drawing item callouts. + +Ownership of ``callout`` is transferred to the item. + +.. note:: + + Callouts are only supported by items which return :py:class:`Qgis`.AnnotationItemFlag.SupportsCallouts from :py:func:`~QgsAnnotationItem.flags`. + +.. seealso:: :py:func:`callout` + +.. seealso:: :py:func:`setCalloutAnchor` + +.. versionadded:: 3.40 +%End + + QgsGeometry calloutAnchor() const; +%Docstring +Returns the callout's anchor geometry. + +The anchor dictates the geometry which the option item :py:func:`~QgsAnnotationItem.callout` should connect to. Depending on the +callout subclass and anchor geometry type, the actual shape of the rendered callout may vary. + +The callout anchor geometry is in the parent layer's coordinate reference system. + +.. seealso:: :py:func:`callout` + +.. seealso:: :py:func:`setCalloutAnchor` + +.. versionadded:: 3.40 +%End + + void setCalloutAnchor( const QgsGeometry &anchor ); +%Docstring +Sets the callout's ``anchor`` geometry. + +The anchor dictates the geometry which the option item :py:func:`~QgsAnnotationItem.callout` should connect to. Depending on the +callout subclass and anchor geometry type, the actual shape of the rendered callout may vary. + +The callout ``anchor`` geometry must be specified in the parent layer's coordinate reference system. + +.. seealso:: :py:func:`setCallout` + +.. seealso:: :py:func:`calloutAnchor` + +.. versionadded:: 3.40 %End protected: @@ -281,6 +351,15 @@ Reads common properties from the base class from the given DOM ``element``. .. seealso:: :py:func:`readXml` .. versionadded:: 3.22 +%End + + void renderCallout( QgsRenderContext &context, const QRectF &rect, double angle, QgsCallout::QgsCalloutContext &calloutContext, QgsFeedback *feedback ); +%Docstring +Renders the item's callout. + +The item must have valid :py:func:`~QgsAnnotationItem.callout` set. + +.. versionadded:: 3.40 %End private: diff --git a/python/core/auto_generated/qgis.sip.in b/python/core/auto_generated/qgis.sip.in index aa41fa2eee99..b1ab1e6c512c 100644 --- a/python/core/auto_generated/qgis.sip.in +++ b/python/core/auto_generated/qgis.sip.in @@ -1281,6 +1281,7 @@ The development version { ScaleDependentBoundingBox, SupportsReferenceScale, + SupportsCallouts, }; typedef QFlags AnnotationItemFlags; @@ -1301,6 +1302,7 @@ The development version enum class AnnotationItemNodeType { VertexHandle, + CalloutHandle, }; enum class AnnotationItemEditOperationResult diff --git a/python/gui/auto_generated/annotations/qgsannotationitemcommonpropertieswidget.sip.in b/python/gui/auto_generated/annotations/qgsannotationitemcommonpropertieswidget.sip.in index 9cd44ce0dc03..f8fd5a94638c 100644 --- a/python/gui/auto_generated/annotations/qgsannotationitemcommonpropertieswidget.sip.in +++ b/python/gui/auto_generated/annotations/qgsannotationitemcommonpropertieswidget.sip.in @@ -27,6 +27,7 @@ A widget for configuring common properties for :py:class:`QgsAnnotationItems` %Docstring Constructor for QgsAnnotationItemCommonPropertiesWidget. %End + ~QgsAnnotationItemCommonPropertiesWidget(); void setItem( QgsAnnotationItem *item ); %Docstring diff --git a/python/gui/auto_generated/qgsrubberband.sip.in b/python/gui/auto_generated/qgsrubberband.sip.in index bcec6b787ac5..51bbf30a5ec4 100644 --- a/python/gui/auto_generated/qgsrubberband.sip.in +++ b/python/gui/auto_generated/qgsrubberband.sip.in @@ -121,7 +121,7 @@ Set to an invalid color to avoid drawing the secondary stroke. Returns the current secondary stroke color. %End - void setWidth( int width ); + void setWidth( double width ); %Docstring Sets the width of the line. Stroke width for polygon. @@ -157,12 +157,12 @@ Calling this function automatically calls setIcon(ICON_SVG) Returns the current icon type to highlight point geometries. %End - void setIconSize( int iconSize ); + void setIconSize( double iconSize ); %Docstring Sets the size of the point icons %End - int iconSize() const; + double iconSize() const; %Docstring Returns the current icon size of the point icons. %End diff --git a/src/core/annotations/qgsannotationitem.cpp b/src/core/annotations/qgsannotationitem.cpp index a40b7e0131a9..31c144fa69cf 100644 --- a/src/core/annotations/qgsannotationitem.cpp +++ b/src/core/annotations/qgsannotationitem.cpp @@ -17,6 +17,13 @@ #include "qgsannotationitem.h" #include "qgsannotationitemnode.h" +#include "qgscalloutsregistry.h" +#include "qgsapplication.h" +#include "qgsrendercontext.h" + +QgsAnnotationItem::QgsAnnotationItem() = default; + +QgsAnnotationItem::~QgsAnnotationItem() = default; Qgis::AnnotationItemFlags QgsAnnotationItem::flags() const { @@ -59,28 +66,104 @@ QList QgsAnnotationItem::nodesV2( const QgsAnnotationItem Q_NOWARN_DEPRECATED_POP } +QgsCallout *QgsAnnotationItem::callout() const +{ + return mCallout.get(); +} + +void QgsAnnotationItem::setCallout( QgsCallout *callout ) +{ + mCallout.reset( callout ); +} + +QgsGeometry QgsAnnotationItem::calloutAnchor() const +{ + return mCalloutAnchor; +} + +void QgsAnnotationItem::setCalloutAnchor( const QgsGeometry &anchor ) +{ + mCalloutAnchor = anchor; +} + void QgsAnnotationItem::copyCommonProperties( const QgsAnnotationItem *other ) { setEnabled( other->enabled() ); setZIndex( other->zIndex() ); setUseSymbologyReferenceScale( other->useSymbologyReferenceScale() ); setSymbologyReferenceScale( other->symbologyReferenceScale() ); + if ( QgsCallout *callout = other->callout() ) + setCallout( callout->clone() ); + else + setCallout( nullptr ); + setCalloutAnchor( other->calloutAnchor() ); } -bool QgsAnnotationItem::writeCommonProperties( QDomElement &element, QDomDocument &, const QgsReadWriteContext & ) const +bool QgsAnnotationItem::writeCommonProperties( QDomElement &element, QDomDocument &doc, const QgsReadWriteContext &context ) const { element.setAttribute( QStringLiteral( "enabled" ), static_cast( enabled() ) ); element.setAttribute( QStringLiteral( "zIndex" ), zIndex() ); element.setAttribute( QStringLiteral( "useReferenceScale" ), useSymbologyReferenceScale() ? QStringLiteral( "1" ) : QStringLiteral( "0" ) ); element.setAttribute( QStringLiteral( "referenceScale" ), qgsDoubleToString( symbologyReferenceScale() ) ); + + if ( mCallout ) + { + element.setAttribute( QStringLiteral( "calloutType" ), mCallout->type() ); + mCallout->saveProperties( doc, element, context ); + } + if ( !mCalloutAnchor.isEmpty() ) + { + element.setAttribute( QStringLiteral( "calloutAnchor" ), mCalloutAnchor.asWkt() ); + } return true; } -bool QgsAnnotationItem::readCommonProperties( const QDomElement &element, const QgsReadWriteContext & ) +bool QgsAnnotationItem::readCommonProperties( const QDomElement &element, const QgsReadWriteContext &context ) { setEnabled( element.attribute( QStringLiteral( "enabled" ), QStringLiteral( "1" ) ).toInt() ); setZIndex( element.attribute( QStringLiteral( "zIndex" ) ).toInt() ); setUseSymbologyReferenceScale( element.attribute( QStringLiteral( "useReferenceScale" ), QStringLiteral( "0" ) ).toInt() ); setSymbologyReferenceScale( element.attribute( QStringLiteral( "referenceScale" ) ).toDouble() ); + + const QString calloutType = element.attribute( QStringLiteral( "calloutType" ) ); + if ( calloutType.isEmpty() ) + { + mCallout.reset(); + } + else + { + mCallout.reset( QgsApplication::calloutRegistry()->createCallout( calloutType, element.firstChildElement( QStringLiteral( "callout" ) ), context ) ); + if ( !mCallout ) + mCallout.reset( QgsCalloutRegistry::defaultCallout() ); + } + + const QString calloutAnchorWkt = element.attribute( QStringLiteral( "calloutAnchor" ) ); + setCalloutAnchor( calloutAnchorWkt.isEmpty() ? QgsGeometry() : QgsGeometry::fromWkt( calloutAnchorWkt ) ); + return true; } + +void QgsAnnotationItem::renderCallout( QgsRenderContext &context, const QRectF &rect, double angle, QgsCallout::QgsCalloutContext &calloutContext, QgsFeedback * ) +{ + if ( !mCallout || mCalloutAnchor.isEmpty() ) + return; + + // anchor must be in painter coordinates + QgsGeometry anchor = mCalloutAnchor; + if ( context.coordinateTransform().isValid() ) + { + try + { + anchor.transform( context.coordinateTransform() ); + } + catch ( QgsCsException & ) + { + return; + } + } + anchor.transform( context.mapToPixel().transform() ); + + mCallout->startRender( context ); + mCallout->render( context, rect, angle, anchor, calloutContext ); + mCallout->stopRender( context ); +} diff --git a/src/core/annotations/qgsannotationitem.h b/src/core/annotations/qgsannotationitem.h index f2d7892713e2..1fcf85a478eb 100644 --- a/src/core/annotations/qgsannotationitem.h +++ b/src/core/annotations/qgsannotationitem.h @@ -20,6 +20,8 @@ #include "qgis_core.h" #include "qgis_sip.h" #include "qgscoordinatereferencesystem.h" +#include "qgsgeometry.h" +#include "qgscallout.h" class QgsFeedback; class QgsMarkerSymbol; @@ -80,14 +82,14 @@ class CORE_EXPORT QgsAnnotationItem public: - QgsAnnotationItem() = default; + QgsAnnotationItem(); #ifndef SIP_RUN QgsAnnotationItem( const QgsAnnotationItem &other ) = delete; QgsAnnotationItem &operator=( const QgsAnnotationItem &other ) = delete; #endif - virtual ~QgsAnnotationItem() = default; + virtual ~QgsAnnotationItem(); /** * Returns item flags. @@ -272,6 +274,66 @@ class CORE_EXPORT QgsAnnotationItem */ void setSymbologyReferenceScale( double scale ) { mReferenceScale = scale; } + /** + * Returns the item's callout renderer, responsible for drawing item callouts. + * + * Ownership is not transferred. + * + * By default items do not have a callout, and it is necessary to be explicitly set + * a callout style (via setCallout() ) and set the callout anchor geometry (via set + * setCalloutAnchor() ). + * + * \note Callouts are only supported by items which return Qgis::AnnotationItemFlag::SupportsCallouts from flags(). + * + * \see setCallout() + * \see calloutAnchor() + * \since QGIS 3.40 + */ + QgsCallout *callout() const; + + /** + * Sets the item's \a callout renderer, responsible for drawing item callouts. + * + * Ownership of \a callout is transferred to the item. + * + * \note Callouts are only supported by items which return Qgis::AnnotationItemFlag::SupportsCallouts from flags(). + * + * \see callout() + * \see setCalloutAnchor() + * \since QGIS 3.40 + */ + void setCallout( QgsCallout *callout SIP_TRANSFER ); + + /** + * Returns the callout's anchor geometry. + * + * The anchor dictates the geometry which the option item callout() should connect to. Depending on the + * callout subclass and anchor geometry type, the actual shape of the rendered callout may vary. + * + * The callout anchor geometry is in the parent layer's coordinate reference system. + * + * \see callout() + * \see setCalloutAnchor() + * + * \since QGIS 3.40 + */ + QgsGeometry calloutAnchor() const; + + /** + * Sets the callout's \a anchor geometry. + * + * The anchor dictates the geometry which the option item callout() should connect to. Depending on the + * callout subclass and anchor geometry type, the actual shape of the rendered callout may vary. + * + * The callout \a anchor geometry must be specified in the parent layer's coordinate reference system. + * + * \see setCallout() + * \see calloutAnchor() + * + * \since QGIS 3.40 + */ + void setCalloutAnchor( const QgsGeometry &anchor ); + protected: /** @@ -297,6 +359,15 @@ class CORE_EXPORT QgsAnnotationItem */ bool readCommonProperties( const QDomElement &element, const QgsReadWriteContext &context ); + /** + * Renders the item's callout. + * + * The item must have valid callout() set. + * + * \since QGIS 3.40 + */ + void renderCallout( QgsRenderContext &context, const QRectF &rect, double angle, QgsCallout::QgsCalloutContext &calloutContext, QgsFeedback *feedback ); + private: int mZIndex = 0; @@ -304,6 +375,9 @@ class CORE_EXPORT QgsAnnotationItem bool mUseReferenceScale = false; double mReferenceScale = 0; + std::unique_ptr< QgsCallout > mCallout; + QgsGeometry mCalloutAnchor; + #ifdef SIP_RUN QgsAnnotationItem( const QgsAnnotationItem &other ); #endif diff --git a/src/core/annotations/qgsannotationpictureitem.cpp b/src/core/annotations/qgsannotationpictureitem.cpp index ede3ade24976..d054c38fb61d 100644 --- a/src/core/annotations/qgsannotationpictureitem.cpp +++ b/src/core/annotations/qgsannotationpictureitem.cpp @@ -29,7 +29,7 @@ #include "qgsfillsymbollayer.h" #include "qgslinesymbollayer.h" #include "qgsunittypes.h" - +#include "qgscalloutsregistry.h" #include @@ -62,14 +62,15 @@ Qgis::AnnotationItemFlags QgsAnnotationPictureItem::flags() const switch ( mSizeMode ) { case Qgis::AnnotationPictureSizeMode::SpatialBounds: - return Qgis::AnnotationItemFlags(); + return Qgis::AnnotationItemFlag::SupportsCallouts; case Qgis::AnnotationPictureSizeMode::FixedSize: - return Qgis::AnnotationItemFlag::ScaleDependentBoundingBox;; + return Qgis::AnnotationItemFlag::ScaleDependentBoundingBox + | Qgis::AnnotationItemFlag::SupportsCallouts; } BUILTIN_UNREACHABLE } -void QgsAnnotationPictureItem::render( QgsRenderContext &context, QgsFeedback * ) +void QgsAnnotationPictureItem::render( QgsRenderContext &context, QgsFeedback *feedback ) { QgsRectangle bounds = mBounds; if ( context.coordinateTransform().isValid() ) @@ -119,6 +120,12 @@ void QgsAnnotationPictureItem::render( QgsRenderContext &context, QgsFeedback * mBackgroundSymbol->stopRender( context ); } + if ( callout() ) + { + QgsCallout::QgsCalloutContext calloutContext; + renderCallout( context, painterBounds, 0, calloutContext, feedback ); + } + bool fitsInCache = false; switch ( mFormat ) { @@ -223,12 +230,14 @@ bool QgsAnnotationPictureItem::writeXml( QDomElement &element, QDomDocument &doc return true; } -QList QgsAnnotationPictureItem::nodesV2( const QgsAnnotationItemEditContext & ) const +QList QgsAnnotationPictureItem::nodesV2( const QgsAnnotationItemEditContext &context ) const { + QList res; switch ( mSizeMode ) { case Qgis::AnnotationPictureSizeMode::SpatialBounds: - return + { + res = { QgsAnnotationItemNode( QgsVertexId( 0, 0, 0 ), QgsPointXY( mBounds.xMinimum(), mBounds.yMinimum() ), Qgis::AnnotationItemNodeType::VertexHandle ), QgsAnnotationItemNode( QgsVertexId( 0, 0, 1 ), QgsPointXY( mBounds.xMaximum(), mBounds.yMinimum() ), Qgis::AnnotationItemNodeType::VertexHandle ), @@ -236,11 +245,40 @@ QList QgsAnnotationPictureItem::nodesV2( const QgsAnnotat QgsAnnotationItemNode( QgsVertexId( 0, 0, 3 ), QgsPointXY( mBounds.xMinimum(), mBounds.yMaximum() ), Qgis::AnnotationItemNodeType::VertexHandle ), }; + QgsPointXY calloutNodePoint; + if ( !calloutAnchor().isEmpty() ) + { + calloutNodePoint = calloutAnchor().asPoint(); + } + else + { + calloutNodePoint = mBounds.center(); + } + res.append( QgsAnnotationItemNode( QgsVertexId( 1, 0, 0 ), calloutNodePoint, Qgis::AnnotationItemNodeType::CalloutHandle ) ); + + return res; + } + case Qgis::AnnotationPictureSizeMode::FixedSize: - return + { + res = { QgsAnnotationItemNode( QgsVertexId( 0, 0, 0 ), mBounds.center(), Qgis::AnnotationItemNodeType::VertexHandle ) }; + + QgsPointXY calloutNodePoint; + if ( !calloutAnchor().isEmpty() ) + { + calloutNodePoint = calloutAnchor().asPoint(); + } + else + { + calloutNodePoint = QgsPointXY( context.currentItemBounds().xMinimum(), context.currentItemBounds().yMinimum() ); + } + res.append( QgsAnnotationItemNode( QgsVertexId( 1, 0, 0 ), calloutNodePoint, Qgis::AnnotationItemNodeType::CalloutHandle ) ); + + return res; + } } BUILTIN_UNREACHABLE } @@ -252,49 +290,61 @@ Qgis::AnnotationItemEditOperationResult QgsAnnotationPictureItem::applyEditV2( Q case QgsAbstractAnnotationItemEditOperation::Type::MoveNode: { QgsAnnotationItemEditOperationMoveNode *moveOperation = dynamic_cast< QgsAnnotationItemEditOperationMoveNode * >( operation ); - switch ( mSizeMode ) + if ( moveOperation->nodeId().part == 0 ) { - case Qgis::AnnotationPictureSizeMode::SpatialBounds: + switch ( mSizeMode ) { - switch ( moveOperation->nodeId().vertex ) + case Qgis::AnnotationPictureSizeMode::SpatialBounds: { - case 0: - mBounds = QgsRectangle( moveOperation->after().x(), - moveOperation->after().y(), - mBounds.xMaximum(), - mBounds.yMaximum() ); - break; - case 1: - mBounds = QgsRectangle( mBounds.xMinimum(), - moveOperation->after().y(), - moveOperation->after().x(), - mBounds.yMaximum() ); - break; - case 2: - mBounds = QgsRectangle( mBounds.xMinimum(), - mBounds.yMinimum(), - moveOperation->after().x(), - moveOperation->after().y() ); - break; - case 3: - mBounds = QgsRectangle( moveOperation->after().x(), - mBounds.yMinimum(), - mBounds.xMaximum(), - moveOperation->after().y() ); - break; - default: - break; + switch ( moveOperation->nodeId().vertex ) + { + case 0: + mBounds = QgsRectangle( moveOperation->after().x(), + moveOperation->after().y(), + mBounds.xMaximum(), + mBounds.yMaximum() ); + break; + case 1: + mBounds = QgsRectangle( mBounds.xMinimum(), + moveOperation->after().y(), + moveOperation->after().x(), + mBounds.yMaximum() ); + break; + case 2: + mBounds = QgsRectangle( mBounds.xMinimum(), + mBounds.yMinimum(), + moveOperation->after().x(), + moveOperation->after().y() ); + break; + case 3: + mBounds = QgsRectangle( moveOperation->after().x(), + mBounds.yMinimum(), + mBounds.xMaximum(), + moveOperation->after().y() ); + break; + default: + break; + } + return Qgis::AnnotationItemEditOperationResult::Success; } - return Qgis::AnnotationItemEditOperationResult::Success; - } - case Qgis::AnnotationPictureSizeMode::FixedSize: + case Qgis::AnnotationPictureSizeMode::FixedSize: + { + mBounds = QgsRectangle::fromCenterAndSize( moveOperation->after(), + mBounds.width(), + mBounds.height() ); + return Qgis::AnnotationItemEditOperationResult::Success; + } + } + } + else if ( moveOperation->nodeId().part == 1 ) + { + setCalloutAnchor( QgsGeometry::fromPoint( moveOperation->after() ) ); + if ( !callout() ) { - mBounds = QgsRectangle::fromCenterAndSize( moveOperation->after(), - mBounds.width(), - mBounds.height() ); - return Qgis::AnnotationItemEditOperationResult::Success; + setCallout( QgsApplication::calloutRegistry()->defaultCallout() ); } + return Qgis::AnnotationItemEditOperationResult::Success; } break; } @@ -323,41 +373,49 @@ QgsAnnotationItemEditOperationTransientResults *QgsAnnotationPictureItem::transi case QgsAbstractAnnotationItemEditOperation::Type::MoveNode: { QgsAnnotationItemEditOperationMoveNode *moveOperation = dynamic_cast< QgsAnnotationItemEditOperationMoveNode * >( operation ); - switch ( mSizeMode ) + if ( moveOperation->nodeId().part == 0 ) { - case Qgis::AnnotationPictureSizeMode::SpatialBounds: + switch ( mSizeMode ) { - QgsRectangle modifiedBounds = mBounds; - switch ( moveOperation->nodeId().vertex ) + case Qgis::AnnotationPictureSizeMode::SpatialBounds: { - case 0: - modifiedBounds.setXMinimum( moveOperation->after().x() ); - modifiedBounds.setYMinimum( moveOperation->after().y() ); - break; - case 1: - modifiedBounds.setXMaximum( moveOperation->after().x() ); - modifiedBounds.setYMinimum( moveOperation->after().y() ); - break; - case 2: - modifiedBounds.setXMaximum( moveOperation->after().x() ); - modifiedBounds.setYMaximum( moveOperation->after().y() ); - break; - case 3: - modifiedBounds.setXMinimum( moveOperation->after().x() ); - modifiedBounds.setYMaximum( moveOperation->after().y() ); - break; - default: - break; + QgsRectangle modifiedBounds = mBounds; + switch ( moveOperation->nodeId().vertex ) + { + case 0: + modifiedBounds.setXMinimum( moveOperation->after().x() ); + modifiedBounds.setYMinimum( moveOperation->after().y() ); + break; + case 1: + modifiedBounds.setXMaximum( moveOperation->after().x() ); + modifiedBounds.setYMinimum( moveOperation->after().y() ); + break; + case 2: + modifiedBounds.setXMaximum( moveOperation->after().x() ); + modifiedBounds.setYMaximum( moveOperation->after().y() ); + break; + case 3: + modifiedBounds.setXMinimum( moveOperation->after().x() ); + modifiedBounds.setYMaximum( moveOperation->after().y() ); + break; + default: + break; + } + return new QgsAnnotationItemEditOperationTransientResults( QgsGeometry::fromRect( modifiedBounds ) ); + } + case Qgis::AnnotationPictureSizeMode::FixedSize: + { + const QgsRectangle currentBounds = context.currentItemBounds(); + const QgsRectangle newBounds = QgsRectangle::fromCenterAndSize( moveOperation->after(), currentBounds.width(), currentBounds.height() ); + return new QgsAnnotationItemEditOperationTransientResults( QgsGeometry::fromRect( newBounds ) ); } - return new QgsAnnotationItemEditOperationTransientResults( QgsGeometry::fromRect( modifiedBounds ) );; - } - case Qgis::AnnotationPictureSizeMode::FixedSize: - { - const QgsRectangle currentBounds = context.currentItemBounds(); - const QgsRectangle newBounds = QgsRectangle::fromCenterAndSize( moveOperation->after(), currentBounds.width(), currentBounds.height() ); - return new QgsAnnotationItemEditOperationTransientResults( QgsGeometry::fromRect( newBounds ) ); } } + else + { + QgsAnnotationItemEditOperationMoveNode *moveOperation = dynamic_cast< QgsAnnotationItemEditOperationMoveNode * >( operation ); + return new QgsAnnotationItemEditOperationTransientResults( QgsGeometry( moveOperation->after().clone() ) ); + } break; } @@ -457,15 +515,26 @@ QgsAnnotationPictureItem *QgsAnnotationPictureItem::clone() const QgsRectangle QgsAnnotationPictureItem::boundingBox() const { + QgsRectangle bounds; switch ( mSizeMode ) { case Qgis::AnnotationPictureSizeMode::SpatialBounds: - return mBounds; + { + bounds = mBounds; + break; + } case Qgis::AnnotationPictureSizeMode::FixedSize: - return QgsRectangle( mBounds.center(), mBounds.center() ); + bounds = QgsRectangle( mBounds.center(), mBounds.center() ); + break; } - BUILTIN_UNREACHABLE + + if ( callout() && !calloutAnchor().isEmpty() ) + { + QgsGeometry anchor = calloutAnchor(); + bounds.combineExtentWith( anchor.boundingBox() ); + } + return bounds; } QgsRectangle QgsAnnotationPictureItem::boundingBox( QgsRenderContext &context ) const @@ -501,7 +570,14 @@ QgsRectangle QgsAnnotationPictureItem::boundingBox( QgsRenderContext &context ) const QgsPointXY bottomRight = context.mapToPixel().toMapCoordinates( boundsInPixels.right(), boundsInPixels.bottom() ); const QgsRectangle boundsMapUnits = QgsRectangle( topLeft.x(), bottomLeft.y(), bottomRight.x(), topRight.y() ); - return context.coordinateTransform().transformBoundingBox( boundsMapUnits, Qgis::TransformDirection::Reverse ); + QgsRectangle textRect = context.coordinateTransform().transformBoundingBox( boundsMapUnits, Qgis::TransformDirection::Reverse ); + + if ( callout() && !calloutAnchor().isEmpty() ) + { + QgsGeometry anchor = calloutAnchor(); + textRect.combineExtentWith( anchor.boundingBox() ); + } + return textRect; } } BUILTIN_UNREACHABLE diff --git a/src/core/annotations/qgsannotationpointtextitem.cpp b/src/core/annotations/qgsannotationpointtextitem.cpp index ae1e50e5ce1b..440083fd08e5 100644 --- a/src/core/annotations/qgsannotationpointtextitem.cpp +++ b/src/core/annotations/qgsannotationpointtextitem.cpp @@ -20,6 +20,8 @@ #include "qgsannotationitemnode.h" #include "qgsannotationitemeditoperation.h" #include "qgsrendercontext.h" +#include "qgsapplication.h" +#include "qgscalloutsregistry.h" QgsAnnotationPointTextItem::QgsAnnotationPointTextItem( const QString &text, QgsPointXY point ) : QgsAnnotationItem() @@ -33,7 +35,8 @@ Qgis::AnnotationItemFlags QgsAnnotationPointTextItem::flags() const { // in truth this should depend on whether the text format is scale dependent or not! return Qgis::AnnotationItemFlag::ScaleDependentBoundingBox - | Qgis::AnnotationItemFlag::SupportsReferenceScale; + | Qgis::AnnotationItemFlag::SupportsReferenceScale + | Qgis::AnnotationItemFlag::SupportsCallouts; } QgsAnnotationPointTextItem::~QgsAnnotationPointTextItem() = default; @@ -43,7 +46,7 @@ QString QgsAnnotationPointTextItem::type() const return QStringLiteral( "pointtext" ); } -void QgsAnnotationPointTextItem::render( QgsRenderContext &context, QgsFeedback * ) +void QgsAnnotationPointTextItem::render( QgsRenderContext &context, QgsFeedback *feedback ) { QPointF pt; if ( context.coordinateTransform().isValid() ) @@ -71,6 +74,18 @@ void QgsAnnotationPointTextItem::render( QgsRenderContext &context, QgsFeedback } const QString displayText = QgsExpression::replaceExpressionText( mText, &context.expressionContext(), &context.distanceArea() ); + + if ( callout() ) + { + const double textWidth = QgsTextRenderer::textWidth( + context, mTextFormat, displayText.split( '\n' ) ); + const double textHeight = QgsTextRenderer::textHeight( + context, mTextFormat, displayText.split( '\n' ) ); + + QgsCallout::QgsCalloutContext calloutContext; + renderCallout( context, QRectF( pt.x(), pt.y() - textHeight, textWidth, textHeight ), angle, calloutContext, feedback ); + } + QgsTextRenderer::drawText( pt, - angle * M_PI / 180.0, QgsTextRenderer::convertQtHAlignment( mAlignment ), displayText.split( '\n' ), context, mTextFormat ); @@ -186,19 +201,40 @@ QgsRectangle QgsAnnotationPointTextItem::boundingBox( QgsRenderContext &context break; } + QgsRectangle textRect; if ( !qgsDoubleNear( angle, 0 ) ) { - return rotateBoundingBoxAroundPoint( mPoint.x(), mPoint.y(), unrotatedRect, angle ); + textRect = rotateBoundingBoxAroundPoint( mPoint.x(), mPoint.y(), unrotatedRect, angle ); } else { - return unrotatedRect; + textRect = unrotatedRect; + } + + if ( callout() && !calloutAnchor().isEmpty() ) + { + QgsGeometry anchor = calloutAnchor(); + textRect.combineExtentWith( anchor.boundingBox() ); } + return textRect; } -QList QgsAnnotationPointTextItem::nodesV2( const QgsAnnotationItemEditContext & ) const +QList QgsAnnotationPointTextItem::nodesV2( const QgsAnnotationItemEditContext &context ) const { - return { QgsAnnotationItemNode( QgsVertexId( 0, 0, 0 ), mPoint, Qgis::AnnotationItemNodeType::VertexHandle )}; + QList res = { QgsAnnotationItemNode( QgsVertexId( 0, 0, 0 ), mPoint, Qgis::AnnotationItemNodeType::VertexHandle )}; + + QgsPointXY calloutNodePoint; + if ( !calloutAnchor().isEmpty() ) + { + calloutNodePoint = calloutAnchor().asPoint(); + } + else + { + calloutNodePoint = context.currentItemBounds().center(); + } + res.append( QgsAnnotationItemNode( QgsVertexId( 0, 0, 1 ), calloutNodePoint, Qgis::AnnotationItemNodeType::CalloutHandle ) ); + + return res; } Qgis::AnnotationItemEditOperationResult QgsAnnotationPointTextItem::applyEditV2( QgsAbstractAnnotationItemEditOperation *operation, const QgsAnnotationItemEditContext & ) @@ -208,7 +244,18 @@ Qgis::AnnotationItemEditOperationResult QgsAnnotationPointTextItem::applyEditV2( case QgsAbstractAnnotationItemEditOperation::Type::MoveNode: { QgsAnnotationItemEditOperationMoveNode *moveOperation = dynamic_cast< QgsAnnotationItemEditOperationMoveNode * >( operation ); - mPoint = moveOperation->after(); + if ( moveOperation->nodeId().vertex == 0 ) + { + mPoint = moveOperation->after(); + } + else if ( moveOperation->nodeId().vertex == 1 ) + { + setCalloutAnchor( QgsGeometry::fromPoint( moveOperation->after() ) ); + if ( !callout() ) + { + setCallout( QgsApplication::calloutRegistry()->defaultCallout() ); + } + } return Qgis::AnnotationItemEditOperationResult::Success; } diff --git a/src/core/annotations/qgsannotationrectangletextitem.cpp b/src/core/annotations/qgsannotationrectangletextitem.cpp index 92cb151d5d34..3a1bf49c0ce3 100644 --- a/src/core/annotations/qgsannotationrectangletextitem.cpp +++ b/src/core/annotations/qgsannotationrectangletextitem.cpp @@ -26,6 +26,8 @@ #include "qgslinesymbollayer.h" #include "qgstextrenderer.h" #include "qgsunittypes.h" +#include "qgsapplication.h" +#include "qgscalloutsregistry.h" QgsAnnotationRectangleTextItem::QgsAnnotationRectangleTextItem( const QString &text, const QgsRectangle &bounds ) : QgsAnnotationItem() @@ -45,7 +47,7 @@ QString QgsAnnotationRectangleTextItem::type() const return QStringLiteral( "recttext" ); } -void QgsAnnotationRectangleTextItem::render( QgsRenderContext &context, QgsFeedback * ) +void QgsAnnotationRectangleTextItem::render( QgsRenderContext &context, QgsFeedback *feedback ) { QgsRectangle bounds = mBounds; if ( context.coordinateTransform().isValid() ) @@ -71,6 +73,12 @@ void QgsAnnotationRectangleTextItem::render( QgsRenderContext &context, QgsFeedb mBackgroundSymbol->stopRender( context ); } + if ( callout() ) + { + QgsCallout::QgsCalloutContext calloutContext; + renderCallout( context, painterBounds, 0, calloutContext, feedback ); + } + const double marginLeft = context.convertToPainterUnits( mMargins.left(), mMarginUnit ); const double marginTop = context.convertToPainterUnits( mMargins.top(), mMarginUnit ); const double marginRight = context.convertToPainterUnits( mMargins.right(), mMarginUnit ); @@ -140,13 +148,26 @@ bool QgsAnnotationRectangleTextItem::writeXml( QDomElement &element, QDomDocumen QList QgsAnnotationRectangleTextItem::nodesV2( const QgsAnnotationItemEditContext & ) const { - return + QList res = { QgsAnnotationItemNode( QgsVertexId( 0, 0, 0 ), QgsPointXY( mBounds.xMinimum(), mBounds.yMinimum() ), Qgis::AnnotationItemNodeType::VertexHandle ), QgsAnnotationItemNode( QgsVertexId( 0, 0, 1 ), QgsPointXY( mBounds.xMaximum(), mBounds.yMinimum() ), Qgis::AnnotationItemNodeType::VertexHandle ), QgsAnnotationItemNode( QgsVertexId( 0, 0, 2 ), QgsPointXY( mBounds.xMaximum(), mBounds.yMaximum() ), Qgis::AnnotationItemNodeType::VertexHandle ), QgsAnnotationItemNode( QgsVertexId( 0, 0, 3 ), QgsPointXY( mBounds.xMinimum(), mBounds.yMaximum() ), Qgis::AnnotationItemNodeType::VertexHandle ), }; + + QgsPointXY calloutNodePoint; + if ( !calloutAnchor().isEmpty() ) + { + calloutNodePoint = calloutAnchor().asPoint(); + } + else + { + calloutNodePoint = mBounds.center(); + } + res.append( QgsAnnotationItemNode( QgsVertexId( 1, 0, 0 ), calloutNodePoint, Qgis::AnnotationItemNodeType::CalloutHandle ) ); + + return res; } Qgis::AnnotationItemEditOperationResult QgsAnnotationRectangleTextItem::applyEditV2( QgsAbstractAnnotationItemEditOperation *operation, const QgsAnnotationItemEditContext & ) @@ -156,35 +177,47 @@ Qgis::AnnotationItemEditOperationResult QgsAnnotationRectangleTextItem::applyEdi case QgsAbstractAnnotationItemEditOperation::Type::MoveNode: { QgsAnnotationItemEditOperationMoveNode *moveOperation = dynamic_cast< QgsAnnotationItemEditOperationMoveNode * >( operation ); - switch ( moveOperation->nodeId().vertex ) + if ( moveOperation->nodeId().part == 0 ) { - case 0: - mBounds = QgsRectangle( moveOperation->after().x(), - moveOperation->after().y(), - mBounds.xMaximum(), - mBounds.yMaximum() ); - break; - case 1: - mBounds = QgsRectangle( mBounds.xMinimum(), - moveOperation->after().y(), - moveOperation->after().x(), - mBounds.yMaximum() ); - break; - case 2: - mBounds = QgsRectangle( mBounds.xMinimum(), - mBounds.yMinimum(), - moveOperation->after().x(), - moveOperation->after().y() ); - break; - case 3: - mBounds = QgsRectangle( moveOperation->after().x(), - mBounds.yMinimum(), - mBounds.xMaximum(), - moveOperation->after().y() ); - break; - default: - break; + switch ( moveOperation->nodeId().vertex ) + { + case 0: + mBounds = QgsRectangle( moveOperation->after().x(), + moveOperation->after().y(), + mBounds.xMaximum(), + mBounds.yMaximum() ); + break; + case 1: + mBounds = QgsRectangle( mBounds.xMinimum(), + moveOperation->after().y(), + moveOperation->after().x(), + mBounds.yMaximum() ); + break; + case 2: + mBounds = QgsRectangle( mBounds.xMinimum(), + mBounds.yMinimum(), + moveOperation->after().x(), + moveOperation->after().y() ); + break; + case 3: + mBounds = QgsRectangle( moveOperation->after().x(), + mBounds.yMinimum(), + mBounds.xMaximum(), + moveOperation->after().y() ); + break; + default: + break; + } } + else + { + setCalloutAnchor( QgsGeometry::fromPoint( moveOperation->after() ) ); + if ( !callout() ) + { + setCallout( QgsApplication::calloutRegistry()->defaultCallout() ); + } + } + return Qgis::AnnotationItemEditOperationResult::Success; } @@ -212,30 +245,38 @@ QgsAnnotationItemEditOperationTransientResults *QgsAnnotationRectangleTextItem:: case QgsAbstractAnnotationItemEditOperation::Type::MoveNode: { QgsAnnotationItemEditOperationMoveNode *moveOperation = dynamic_cast< QgsAnnotationItemEditOperationMoveNode * >( operation ); - QgsRectangle modifiedBounds = mBounds; - switch ( moveOperation->nodeId().vertex ) + if ( moveOperation->nodeId().part == 0 ) { - case 0: - modifiedBounds.setXMinimum( moveOperation->after().x() ); - modifiedBounds.setYMinimum( moveOperation->after().y() ); - break; - case 1: - modifiedBounds.setXMaximum( moveOperation->after().x() ); - modifiedBounds.setYMinimum( moveOperation->after().y() ); - break; - case 2: - modifiedBounds.setXMaximum( moveOperation->after().x() ); - modifiedBounds.setYMaximum( moveOperation->after().y() ); - break; - case 3: - modifiedBounds.setXMinimum( moveOperation->after().x() ); - modifiedBounds.setYMaximum( moveOperation->after().y() ); - break; - default: - break; + QgsRectangle modifiedBounds = mBounds; + switch ( moveOperation->nodeId().vertex ) + { + case 0: + modifiedBounds.setXMinimum( moveOperation->after().x() ); + modifiedBounds.setYMinimum( moveOperation->after().y() ); + break; + case 1: + modifiedBounds.setXMaximum( moveOperation->after().x() ); + modifiedBounds.setYMinimum( moveOperation->after().y() ); + break; + case 2: + modifiedBounds.setXMaximum( moveOperation->after().x() ); + modifiedBounds.setYMaximum( moveOperation->after().y() ); + break; + case 3: + modifiedBounds.setXMinimum( moveOperation->after().x() ); + modifiedBounds.setYMaximum( moveOperation->after().y() ); + break; + default: + break; + } + + return new QgsAnnotationItemEditOperationTransientResults( QgsGeometry::fromRect( modifiedBounds ) ); + } + else + { + QgsAnnotationItemEditOperationMoveNode *moveOperation = dynamic_cast< QgsAnnotationItemEditOperationMoveNode * >( operation ); + return new QgsAnnotationItemEditOperationTransientResults( QgsGeometry( moveOperation->after().clone() ) ); } - - return new QgsAnnotationItemEditOperationTransientResults( QgsGeometry::fromRect( modifiedBounds ) ); } case QgsAbstractAnnotationItemEditOperation::Type::TranslateItem: @@ -324,7 +365,13 @@ QgsAnnotationRectangleTextItem *QgsAnnotationRectangleTextItem::clone() const QgsRectangle QgsAnnotationRectangleTextItem::boundingBox() const { - return mBounds; + QgsRectangle bounds = mBounds; + if ( callout() && !calloutAnchor().isEmpty() ) + { + QgsGeometry anchor = calloutAnchor(); + bounds.combineExtentWith( anchor.boundingBox() ); + } + return bounds; } void QgsAnnotationRectangleTextItem::setBounds( const QgsRectangle &bounds ) @@ -354,7 +401,8 @@ void QgsAnnotationRectangleTextItem::setFrameSymbol( QgsFillSymbol *symbol ) Qgis::AnnotationItemFlags QgsAnnotationRectangleTextItem::flags() const { - return Qgis::AnnotationItemFlag::SupportsReferenceScale; + return Qgis::AnnotationItemFlag::SupportsReferenceScale + | Qgis::AnnotationItemFlag::SupportsCallouts; } QgsTextFormat QgsAnnotationRectangleTextItem::format() const diff --git a/src/core/qgis.h b/src/core/qgis.h index 4a45522a53aa..020ced81ff43 100644 --- a/src/core/qgis.h +++ b/src/core/qgis.h @@ -2180,6 +2180,7 @@ class CORE_EXPORT Qgis { ScaleDependentBoundingBox = 1 << 0, //!< Item's bounding box will vary depending on map scale SupportsReferenceScale = 1 << 1, //!< Item supports reference scale based rendering (since QGIS 3.40) + SupportsCallouts = 1 << 2, //!< Item supports callouts (since QGIS 3.40) }; //! Annotation item flags Q_DECLARE_FLAGS( AnnotationItemFlags, AnnotationItemFlag ) @@ -2220,6 +2221,7 @@ class CORE_EXPORT Qgis enum class AnnotationItemNodeType : int { VertexHandle, //!< Node is a handle for manipulating vertices + CalloutHandle, //!< Node is a handle for manipulating callouts (since QGIS 3.40) }; Q_ENUM( AnnotationItemNodeType ) diff --git a/src/gui/annotations/qgsannotationitemcommonpropertieswidget.cpp b/src/gui/annotations/qgsannotationitemcommonpropertieswidget.cpp index 68fdc3befab2..d5c9f7958756 100644 --- a/src/gui/annotations/qgsannotationitemcommonpropertieswidget.cpp +++ b/src/gui/annotations/qgsannotationitemcommonpropertieswidget.cpp @@ -15,6 +15,9 @@ #include "qgsannotationitemcommonpropertieswidget.h" #include "qgsannotationitem.h" +#include "qgscalloutpanelwidget.h" +#include "qgsapplication.h" +#include "qgscalloutsregistry.h" QgsAnnotationItemCommonPropertiesWidget::QgsAnnotationItemCommonPropertiesWidget( QWidget *parent ) : QWidget( parent ) @@ -38,14 +41,25 @@ QgsAnnotationItemCommonPropertiesWidget::QgsAnnotationItemCommonPropertiesWidget if ( !mBlockChangedSignal ) emit itemChanged(); } ); + connect( mCalloutCheckBox, &QCheckBox::toggled, this, [ = ] + { + if ( !mBlockChangedSignal ) + emit itemChanged(); + } ); + + connect( mCalloutPropertiesButton, &QToolButton::clicked, this, &QgsAnnotationItemCommonPropertiesWidget::openCalloutProperties ); } +QgsAnnotationItemCommonPropertiesWidget::~QgsAnnotationItemCommonPropertiesWidget() = default; + void QgsAnnotationItemCommonPropertiesWidget::setItem( QgsAnnotationItem *item ) { mSpinZIndex->setValue( item->zIndex() ); mReferenceScaleGroup->setChecked( item->useSymbologyReferenceScale() ); mReferenceScaleWidget->setScale( item->symbologyReferenceScale() ); mReferenceScaleGroup->setVisible( item->flags() & Qgis::AnnotationItemFlag::SupportsReferenceScale ); + mCalloutCheckBox->setChecked( item->callout() ); + mCallout.reset( item->callout() ? item->callout()->clone() : nullptr ); } void QgsAnnotationItemCommonPropertiesWidget::updateItem( QgsAnnotationItem *item ) @@ -53,6 +67,7 @@ void QgsAnnotationItemCommonPropertiesWidget::updateItem( QgsAnnotationItem *ite item->setZIndex( mSpinZIndex->value() ); item->setUseSymbologyReferenceScale( mReferenceScaleGroup->isChecked() ); item->setSymbologyReferenceScale( mReferenceScaleWidget->scale() ); + item->setCallout( mCallout && mCalloutCheckBox->isChecked() ? mCallout->clone() : nullptr ); } void QgsAnnotationItemCommonPropertiesWidget::setContext( const QgsSymbolWidgetContext &context ) @@ -67,3 +82,23 @@ QgsSymbolWidgetContext QgsAnnotationItemCommonPropertiesWidget::context() const { return mContext; } + +void QgsAnnotationItemCommonPropertiesWidget::openCalloutProperties() +{ + QgsCalloutPanelWidget *widget = new QgsCalloutPanelWidget(); + if ( !mCallout ) + mCallout.reset( QgsApplication::calloutRegistry()->defaultCallout() ); + widget->setCallout( mCallout.get() ); + + connect( widget, &QgsCalloutPanelWidget::calloutChanged, this, [this, widget] + { + mCallout.reset( widget->callout()->clone() ); + if ( !mBlockChangedSignal ) + emit itemChanged(); + } ); + + if ( QgsPanelWidget *panel = QgsPanelWidget::findParentPanel( this ) ) + { + panel->openPanel( widget ); + } +} diff --git a/src/gui/annotations/qgsannotationitemcommonpropertieswidget.h b/src/gui/annotations/qgsannotationitemcommonpropertieswidget.h index baaad11c75f8..653a88e39ad7 100644 --- a/src/gui/annotations/qgsannotationitemcommonpropertieswidget.h +++ b/src/gui/annotations/qgsannotationitemcommonpropertieswidget.h @@ -22,6 +22,7 @@ #include "qgssymbolwidgetcontext.h" class QgsAnnotationItem; +class QgsCallout; /** * \class QgsAnnotationItemCommonPropertiesWidget @@ -41,6 +42,7 @@ class GUI_EXPORT QgsAnnotationItemCommonPropertiesWidget: public QWidget, privat * Constructor for QgsAnnotationItemCommonPropertiesWidget. */ QgsAnnotationItemCommonPropertiesWidget( QWidget *parent SIP_TRANSFERTHIS ); + ~QgsAnnotationItemCommonPropertiesWidget() override; /** * Sets the \a item whose properties should be shown in the widget. @@ -71,12 +73,18 @@ class GUI_EXPORT QgsAnnotationItemCommonPropertiesWidget: public QWidget, privat */ void itemChanged(); + private slots: + + void openCalloutProperties(); + private: bool mBlockChangedSignal = false; //! Context in which widget is shown QgsSymbolWidgetContext mContext; + + std::unique_ptr< QgsCallout > mCallout; }; #endif // QGSANNOTATIONITEMCOMMONPROPERTIESWIDGET_H diff --git a/src/gui/annotations/qgsmaptoolmodifyannotation.cpp b/src/gui/annotations/qgsmaptoolmodifyannotation.cpp index 16084e79e4bf..3c5f5fe43261 100644 --- a/src/gui/annotations/qgsmaptoolmodifyannotation.cpp +++ b/src/gui/annotations/qgsmaptoolmodifyannotation.cpp @@ -647,6 +647,13 @@ void QgsMapToolModifyAnnotation::setHoveredItem( const QgsRenderedAnnotationItem vertexNodeBand->setIconSize( scaleFactor * 5 ); vertexNodeBand->setColor( QColor( 200, 0, 120, 255 ) ); + QgsRubberBand *calloutNodeBand = new QgsRubberBand( mCanvas, Qgis::GeometryType::Point ); + calloutNodeBand->setWidth( scaleFactor ); + calloutNodeBand->setSecondaryStrokeColor( QColor( 255, 255, 255, 100 ) ); + calloutNodeBand->setColor( QColor( 120, 200, 0, 255 ) ); + calloutNodeBand->setIcon( QgsRubberBand::ICON_X ); + calloutNodeBand->setIconSize( scaleFactor * 5 ); + // store item nodes in a spatial index for quick searching mHoveredItemNodesSpatialIndex = std::make_unique< QgsAnnotationItemNodesSpatialIndex >(); int index = 0; @@ -669,6 +676,10 @@ void QgsMapToolModifyAnnotation::setHoveredItem( const QgsRenderedAnnotationItem case Qgis::AnnotationItemNodeType::VertexHandle: vertexNodeBand->addPoint( nodeMapPoint ); break; + + case Qgis::AnnotationItemNodeType::CalloutHandle: + calloutNodeBand->addPoint( nodeMapPoint ); + break; } mHoveredItemNodesSpatialIndex->insert( index, QgsRectangle( nodeMapPoint.x(), nodeMapPoint.y(), @@ -682,6 +693,7 @@ void QgsMapToolModifyAnnotation::setHoveredItem( const QgsRenderedAnnotationItem } mHoveredItemNodeRubberBands.emplace_back( vertexNodeBand ); + mHoveredItemNodeRubberBands.emplace_back( calloutNodeBand ); } QSizeF QgsMapToolModifyAnnotation::deltaForKeyEvent( QgsAnnotationLayer *layer, const QgsPointXY &originalCanvasPoint, QKeyEvent *event ) diff --git a/src/gui/qgsrubberband.cpp b/src/gui/qgsrubberband.cpp index bb6d8bb49f98..aad53c93e7e5 100644 --- a/src/gui/qgsrubberband.cpp +++ b/src/gui/qgsrubberband.cpp @@ -75,9 +75,9 @@ void QgsRubberBand::setSecondaryStrokeColor( const QColor &color ) mSecondaryPen.setColor( color ); } -void QgsRubberBand::setWidth( int width ) +void QgsRubberBand::setWidth( double width ) { - mPen.setWidth( width ); + mPen.setWidthF( width ); } void QgsRubberBand::setIcon( IconType icon ) @@ -92,7 +92,7 @@ void QgsRubberBand::setSvgIcon( const QString &path, QPoint drawOffset ) mSvgOffset = drawOffset; } -void QgsRubberBand::setIconSize( int iconSize ) +void QgsRubberBand::setIconSize( double iconSize ) { mIconSize = iconSize; } @@ -508,7 +508,7 @@ void QgsRubberBand::paint( QPainter *p ) if ( i == 0 && iterations > 1 ) { // first iteration with multi-pen painting, so use secondary pen - mSecondaryPen.setWidth( mPen.width() + QgsGuiUtils::scaleIconSize( 2 ) ); + mSecondaryPen.setWidthF( mPen.widthF() + QgsGuiUtils::scaleIconSize( 2 ) ); p->setBrush( Qt::NoBrush ); p->setPen( mSecondaryPen ); } @@ -587,11 +587,11 @@ void QgsRubberBand::drawShape( QPainter *p, const QVector &pts ) break; case ICON_FULL_BOX: - p->drawRect( static_cast< int>( x - s ), static_cast< int >( y - s ), mIconSize, mIconSize ); + p->drawRect( QRectF( static_cast< int>( x - s ), static_cast< int >( y - s ), mIconSize, mIconSize ) ); break; case ICON_CIRCLE: - p->drawEllipse( static_cast< int >( x - s ), static_cast< int >( y - s ), mIconSize, mIconSize ); + p->drawEllipse( QRectF( static_cast< int >( x - s ), static_cast< int >( y - s ), mIconSize, mIconSize ) ); break; case ICON_DIAMOND: @@ -654,7 +654,7 @@ void QgsRubberBand::updateRect() } #endif - qreal w = ( ( mIconSize - 1 ) / 2 + mPen.width() ); // in canvas units + qreal w = ( ( mIconSize - 1 ) / 2 + mPen.widthF() ); // in canvas units QgsRectangle r; // in canvas units for ( const QgsPolygonXY &poly : std::as_const( mPoints ) ) diff --git a/src/gui/qgsrubberband.h b/src/gui/qgsrubberband.h index 9cdf3c4e4f46..518ed72613e5 100644 --- a/src/gui/qgsrubberband.h +++ b/src/gui/qgsrubberband.h @@ -187,7 +187,7 @@ class GUI_EXPORT QgsRubberBand : public QgsMapCanvasItem * Sets the width of the line. Stroke width for polygon. * \param width The width for any lines painted for this rubberband */ - void setWidth( int width ); + void setWidth( double width ); /** * Returns the current width of the line or stroke width for polygon. @@ -218,12 +218,12 @@ class GUI_EXPORT QgsRubberBand : public QgsMapCanvasItem /** * Sets the size of the point icons */ - void setIconSize( int iconSize ); + void setIconSize( double iconSize ); /** * Returns the current icon size of the point icons. */ - int iconSize() const { return mIconSize; } + double iconSize() const { return mIconSize; } /** * Sets the style of the line @@ -449,7 +449,7 @@ class GUI_EXPORT QgsRubberBand : public QgsMapCanvasItem QPen mSecondaryPen; //! The size of the icon for points. - int mIconSize = 5; + double mIconSize = 5; //! Icon to be shown. IconType mIconType = ICON_CIRCLE; diff --git a/src/ui/annotations/qgsannotationcommonpropertieswidgetbase.ui b/src/ui/annotations/qgsannotationcommonpropertieswidgetbase.ui index c27a24c2e424..b4bad2755856 100644 --- a/src/ui/annotations/qgsannotationcommonpropertieswidgetbase.ui +++ b/src/ui/annotations/qgsannotationcommonpropertieswidgetbase.ui @@ -7,7 +7,7 @@ 0 0 321 - 325 + 155 @@ -26,29 +26,7 @@ 0 - - - - Reference Scale - - - true - - - - - - Qt::StrongFocus - - - Minimum scale, i.e. most "zoomed out". - - - - - - - + Rendering @@ -74,6 +52,46 @@ + + + + + + Show callout + + + + + + + ... + + + + + + + + + Reference Scale + + + true + + + + + + Qt::StrongFocus + + + Minimum scale, i.e. most "zoomed out". + + + + + + diff --git a/tests/src/python/test_qgsannotationpictureitem.py b/tests/src/python/test_qgsannotationpictureitem.py index 29c31986857a..931e1cb462c6 100644 --- a/tests/src/python/test_qgsannotationpictureitem.py +++ b/tests/src/python/test_qgsannotationpictureitem.py @@ -35,6 +35,10 @@ QgsRectangle, QgsRenderContext, QgsVertexId, + QgsCallout, + QgsBalloonCallout, + QgsGeometry, + QgsSimpleLineCallout ) import unittest from qgis.testing import start_app, QgisTestCase @@ -101,7 +105,8 @@ def test_nodes_spatial_bounds(self): QgsAnnotationItemNode(QgsVertexId(0, 0, 0), QgsPointXY(10, 20), Qgis.AnnotationItemNodeType.VertexHandle), QgsAnnotationItemNode(QgsVertexId(0, 0, 1), QgsPointXY(30, 20), Qgis.AnnotationItemNodeType.VertexHandle), QgsAnnotationItemNode(QgsVertexId(0, 0, 2), QgsPointXY(30, 40), Qgis.AnnotationItemNodeType.VertexHandle), - QgsAnnotationItemNode(QgsVertexId(0, 0, 3), QgsPointXY(10, 40), Qgis.AnnotationItemNodeType.VertexHandle)]) + QgsAnnotationItemNode(QgsVertexId(0, 0, 3), QgsPointXY(10, 40), Qgis.AnnotationItemNodeType.VertexHandle), + QgsAnnotationItemNode(QgsVertexId(1, 0, 0), QgsPointXY(20, 30), Qgis.AnnotationItemNodeType.CalloutHandle)]) def test_nodes_fixed_size(self): """ @@ -110,8 +115,11 @@ def test_nodes_fixed_size(self): item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, self.get_test_data_path('rgb256x256.png').as_posix(), QgsRectangle(10, 20, 30, 40)) item.setSizeMode(Qgis.AnnotationPictureSizeMode.FixedSize) - self.assertEqual(item.nodesV2(QgsAnnotationItemEditContext()), [ - QgsAnnotationItemNode(QgsVertexId(0, 0, 0), QgsPointXY(20, 30), Qgis.AnnotationItemNodeType.VertexHandle)]) + context = QgsAnnotationItemEditContext() + context.setCurrentItemBounds(QgsRectangle(10, 20, 30, 40)) + self.assertEqual(item.nodesV2(context), [ + QgsAnnotationItemNode(QgsVertexId(0, 0, 0), QgsPointXY(20, 30), Qgis.AnnotationItemNodeType.VertexHandle), + QgsAnnotationItemNode(QgsVertexId(1, 0, 0), QgsPointXY(10, 20), Qgis.AnnotationItemNodeType.CalloutHandle)]) def test_translate_spatial_bounds(self): item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, @@ -166,6 +174,13 @@ def test_apply_move_node_edit_spatial_bounds(self): Qgis.AnnotationItemEditOperationResult.Success) self.assertEqual(item.bounds().toString(3), '2.000,13.000 : 18.000,39.000') + # move callout handle + self.assertEqual(item.applyEditV2(QgsAnnotationItemEditOperationMoveNode('', QgsVertexId(1, 0, 0), QgsPoint(14, 13), QgsPoint(1, 3)), QgsAnnotationItemEditContext()), Qgis.AnnotationItemEditOperationResult.Success) + self.assertEqual(item.bounds().toString(3), '2.000,13.000 : 18.000,39.000') + self.assertEqual(item.calloutAnchor().asWkt(), 'Point (1 3)') + # callout should have been automatically created + self.assertIsInstance(item.callout(), QgsCallout) + def test_apply_move_node_edit_fixed_size(self): item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, self.get_test_data_path( @@ -180,6 +195,13 @@ def test_apply_move_node_edit_fixed_size(self): Qgis.AnnotationItemEditOperationResult.Success) self.assertEqual(item.bounds().toString(3), '7.000,8.000 : 27.000,28.000') + # move callout handle + self.assertEqual(item.applyEditV2(QgsAnnotationItemEditOperationMoveNode('', QgsVertexId(1, 0, 0), QgsPoint(14, 13), QgsPoint(1, 3)), QgsAnnotationItemEditContext()), Qgis.AnnotationItemEditOperationResult.Success) + self.assertEqual(item.bounds().toString(3), '7.000,8.000 : 27.000,28.000') + self.assertEqual(item.calloutAnchor().asWkt(), 'Point (1 3)') + # callout should have been automatically created + self.assertIsInstance(item.callout(), QgsCallout) + def test_apply_delete_node_edit(self): item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, self.get_test_data_path( @@ -211,6 +233,11 @@ def test_transient_move_operation_spatial_bounds(self): QgsAnnotationItemEditContext()) self.assertEqual(res.representativeGeometry().asWkt(), 'Polygon ((10 18, 17 18, 17 40, 10 40, 10 18))') + # move callout handle + res = item.transientEditResultsV2(QgsAnnotationItemEditOperationMoveNode('', QgsVertexId(1, 0, 0), QgsPoint(14, 13), QgsPoint(1, 3)), QgsAnnotationItemEditContext()) + self.assertEqual(res.representativeGeometry().asWkt(), + 'Point (1 3)') + def test_transient_move_operation_fixed_size(self): item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, self.get_test_data_path( @@ -228,6 +255,11 @@ def test_transient_move_operation_fixed_size(self): res = item.transientEditResultsV2(op, context) self.assertEqual(res.representativeGeometry().asWkt(), 'Polygon ((16 17, 18 17, 18 19, 16 19, 16 17))') + # move callout handle + res = item.transientEditResultsV2(QgsAnnotationItemEditOperationMoveNode('', QgsVertexId(1, 0, 0), QgsPoint(14, 13), QgsPoint(1, 3)), QgsAnnotationItemEditContext()) + self.assertEqual(res.representativeGeometry().asWkt(), + 'Point (1 3)') + def test_transient_translate_operation_spatial_bounds(self): item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, self.get_test_data_path( @@ -274,6 +306,8 @@ def testReadWriteXml(self): item.setFixedSize(QSizeF(56, 57)) item.setFixedSizeUnit(Qgis.RenderUnit.Inches) + item.setCalloutAnchor(QgsGeometry.fromWkt('Point(1 3)')) + item.setCallout(QgsBalloonCallout()) self.assertTrue(item.writeXml(elem, doc, QgsReadWriteContext())) @@ -295,6 +329,8 @@ def testReadWriteXml(self): self.assertEqual(s2.fixedSize(), QSizeF(56, 57)) self.assertEqual(s2.fixedSizeUnit(), Qgis.RenderUnit.Inches) + self.assertEqual(s2.calloutAnchor().asWkt(), 'Point (1 3)') + self.assertIsInstance(s2.callout(), QgsBalloonCallout) def testClone(self): item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, self.get_test_data_path('rgb256x256.png').as_posix(), @@ -309,6 +345,8 @@ def testClone(self): item.setFixedSize(QSizeF(56, 57)) item.setFixedSizeUnit(Qgis.RenderUnit.Inches) + item.setCalloutAnchor(QgsGeometry.fromWkt('Point(1 3)')) + item.setCallout(QgsBalloonCallout()) s2 = item.clone() self.assertEqual(s2.bounds().toString(3), '10.000,20.000 : 30.000,40.000') @@ -326,6 +364,8 @@ def testClone(self): self.assertEqual(s2.fixedSize(), QSizeF(56, 57)) self.assertEqual(s2.fixedSizeUnit(), Qgis.RenderUnit.Inches) + self.assertEqual(s2.calloutAnchor().asWkt(), 'Point (1 3)') + self.assertIsInstance(s2.callout(), QgsBalloonCallout) def testRenderRasterLockedAspect(self): item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, self.get_test_data_path('rgb256x256.png').as_posix(), @@ -464,6 +504,38 @@ def testRenderWithTransform(self): self.assertTrue(self.image_check('picture_transform', 'picture_transform', image)) + def testRenderCallout(self): + item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, self.get_test_data_path('rgb256x256.png').as_posix(), + QgsRectangle(12, 13, 16, 15)) + item.setLockAspectRatio(True) + + callout = QgsSimpleLineCallout() + callout.lineSymbol().setWidth(1) + item.setCallout(callout) + item.setCalloutAnchor(QgsGeometry.fromWkt('Point(11 12)')) + + settings = QgsMapSettings() + settings.setDestinationCrs(QgsCoordinateReferenceSystem('EPSG:4326')) + settings.setExtent(QgsRectangle(10, 8, 18, 16)) + settings.setOutputSize(QSize(300, 300)) + + settings.setFlag(QgsMapSettings.Flag.Antialiasing, False) + + rc = QgsRenderContext.fromMapSettings(settings) + image = QImage(200, 200, QImage.Format.Format_ARGB32) + image.setDotsPerMeterX(int(96 / 25.4 * 1000)) + image.setDotsPerMeterY(int(96 / 25.4 * 1000)) + image.fill(QColor(255, 255, 255)) + painter = QPainter(image) + rc.setPainter(painter) + + try: + item.render(rc, None) + finally: + painter.end() + + self.assertTrue(self.image_check('picture_callout', 'picture_callout', image)) + def testRenderFixedSizeRaster(self): item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, self.get_test_data_path('rgb256x256.png').as_posix(), QgsRectangle(12, 13, 16, 15)) @@ -495,6 +567,42 @@ def testRenderFixedSizeRaster(self): self.assertTrue(self.image_check('picture_fixed_size_raster', 'picture_fixed_size_raster', image)) + def testRenderFixedSizeCallout(self): + item = QgsAnnotationPictureItem(Qgis.PictureFormat.Raster, self.get_test_data_path('rgb256x256.png').as_posix(), + QgsRectangle(12, 13, 16, 15)) + item.setLockAspectRatio(True) + item.setSizeMode(Qgis.AnnotationPictureSizeMode.FixedSize) + item.setFixedSize(QSizeF(10, + 20)) + item.setFixedSizeUnit(Qgis.RenderUnit.Millimeters) + + callout = QgsSimpleLineCallout() + callout.lineSymbol().setWidth(1) + item.setCallout(callout) + item.setCalloutAnchor(QgsGeometry.fromWkt('Point(11 12)')) + + settings = QgsMapSettings() + settings.setDestinationCrs(QgsCoordinateReferenceSystem('EPSG:4326')) + settings.setExtent(QgsRectangle(10, 8, 18, 16)) + settings.setOutputSize(QSize(300, 300)) + + settings.setFlag(QgsMapSettings.Flag.Antialiasing, False) + + rc = QgsRenderContext.fromMapSettings(settings) + image = QImage(200, 200, QImage.Format.Format_ARGB32) + image.setDotsPerMeterX(int(96 / 25.4 * 1000)) + image.setDotsPerMeterY(int(96 / 25.4 * 1000)) + image.fill(QColor(255, 255, 255)) + painter = QPainter(image) + rc.setPainter(painter) + + try: + item.render(rc, None) + finally: + painter.end() + + self.assertTrue(self.image_check('picture_fixed_size_callout', 'picture_fixed_size_callout', image)) + def testRenderSvgFixedSize(self): item = QgsAnnotationPictureItem(Qgis.PictureFormat.SVG, self.get_test_data_path('sample_svg.svg').as_posix(), QgsRectangle(12, 13, 16, 15)) diff --git a/tests/src/python/test_qgsannotationpointtextitem.py b/tests/src/python/test_qgsannotationpointtextitem.py index 88642b99b805..919791b0e1ff 100644 --- a/tests/src/python/test_qgsannotationpointtextitem.py +++ b/tests/src/python/test_qgsannotationpointtextitem.py @@ -34,6 +34,10 @@ QgsRenderContext, QgsTextFormat, QgsVertexId, + QgsCallout, + QgsBalloonCallout, + QgsGeometry, + QgsSimpleLineCallout ) import unittest @@ -83,7 +87,11 @@ def test_nodes(self): Test nodes for item """ item = QgsAnnotationPointTextItem('my text', QgsPointXY(12, 13)) - self.assertEqual(item.nodesV2(QgsAnnotationItemEditContext()), [QgsAnnotationItemNode(QgsVertexId(0, 0, 0), QgsPointXY(12, 13), Qgis.AnnotationItemNodeType.VertexHandle)]) + context = QgsAnnotationItemEditContext() + context.setCurrentItemBounds(QgsRectangle(12, 13, 20, 23)) + self.assertEqual(item.nodesV2(context), + [QgsAnnotationItemNode(QgsVertexId(0, 0, 0), QgsPointXY(12, 13), Qgis.AnnotationItemNodeType.VertexHandle), + QgsAnnotationItemNode(QgsVertexId(0, 0, 1), QgsPointXY(16, 18), Qgis.AnnotationItemNodeType.CalloutHandle)]) def test_transform(self): item = QgsAnnotationPointTextItem('my text', QgsPointXY(12, 13)) @@ -99,6 +107,13 @@ def test_apply_move_node_edit(self): self.assertEqual(item.applyEditV2(QgsAnnotationItemEditOperationMoveNode('', QgsVertexId(0, 0, 0), QgsPoint(14, 13), QgsPoint(17, 18)), QgsAnnotationItemEditContext()), Qgis.AnnotationItemEditOperationResult.Success) self.assertEqual(item.point().asWkt(), 'POINT(17 18)') + # move callout handle + self.assertEqual(item.applyEditV2(QgsAnnotationItemEditOperationMoveNode('', QgsVertexId(0, 0, 1), QgsPoint(14, 13), QgsPoint(1, 3)), QgsAnnotationItemEditContext()), Qgis.AnnotationItemEditOperationResult.Success) + self.assertEqual(item.point().asWkt(), 'POINT(17 18)') + self.assertEqual(item.calloutAnchor().asWkt(), 'Point (1 3)') + # callout should have been automatically created + self.assertIsInstance(item.callout(), QgsCallout) + def test_transient_move_operation(self): item = QgsAnnotationPointTextItem('my text', QgsPointXY(12, 13)) self.assertEqual(item.point().asWkt(), 'POINT(12 13)') @@ -137,6 +152,8 @@ def testReadWriteXml(self): item.setUseSymbologyReferenceScale(True) item.setSymbologyReferenceScale(5000) item.setRotationMode(Qgis.SymbolRotationMode.RespectMapRotation) + item.setCalloutAnchor(QgsGeometry.fromWkt('Point(1 3)')) + item.setCallout(QgsBalloonCallout()) self.assertTrue(item.writeXml(elem, doc, QgsReadWriteContext())) @@ -152,6 +169,8 @@ def testReadWriteXml(self): self.assertTrue(s2.useSymbologyReferenceScale()) self.assertEqual(s2.symbologyReferenceScale(), 5000) self.assertEqual(s2.rotationMode(), Qgis.SymbolRotationMode.RespectMapRotation) + self.assertEqual(s2.calloutAnchor().asWkt(), 'Point (1 3)') + self.assertIsInstance(s2.callout(), QgsBalloonCallout) def testClone(self): item = QgsAnnotationPointTextItem('my text', QgsPointXY(12, 13)) @@ -164,6 +183,8 @@ def testClone(self): item.setUseSymbologyReferenceScale(True) item.setSymbologyReferenceScale(5000) item.setRotationMode(Qgis.SymbolRotationMode.RespectMapRotation) + item.setCalloutAnchor(QgsGeometry.fromWkt('Point(1 3)')) + item.setCallout(QgsBalloonCallout()) item2 = item.clone() self.assertEqual(item2.text(), 'my text') @@ -176,6 +197,8 @@ def testClone(self): self.assertTrue(item2.useSymbologyReferenceScale()) self.assertEqual(item2.symbologyReferenceScale(), 5000) self.assertEqual(item2.rotationMode(), Qgis.SymbolRotationMode.RespectMapRotation) + self.assertEqual(item2.calloutAnchor().asWkt(), 'Point (1 3)') + self.assertIsInstance(item2.callout(), QgsBalloonCallout) def testRenderMarker(self): item = QgsAnnotationPointTextItem('my text', QgsPointXY(12.3, 13.2)) @@ -357,6 +380,42 @@ def testRenderWithTransform(self): self.assertTrue(self.image_check('pointtext_item_transform', 'pointtext_item_transform', image)) + def testRenderCallout(self): + item = QgsAnnotationPointTextItem('my text', QgsPointXY(12.3, 13.2)) + item.setCalloutAnchor(QgsGeometry.fromWkt('Point(1 5)')) + callout = QgsSimpleLineCallout() + callout.lineSymbol().setWidth(1) + item.setCallout(callout) + + format = QgsTextFormat.fromQFont(getTestFont('Bold')) + format.setColor(QColor(255, 0, 0)) + format.setOpacity(150 / 255) + format.setSize(20) + item.setFormat(format) + + settings = QgsMapSettings() + settings.setDestinationCrs(QgsCoordinateReferenceSystem('EPSG:4326')) + settings.setExtent(QgsRectangle(0, 5, 26, 11)) + settings.setOutputSize(QSize(300, 300)) + + settings.setFlag(QgsMapSettings.Flag.Antialiasing, False) + + rc = QgsRenderContext.fromMapSettings(settings) + rc.setScaleFactor(96 / 25.4) # 96 DPI + image = QImage(200, 200, QImage.Format.Format_ARGB32) + image.setDotsPerMeterX(int(96 / 25.4 * 1000)) + image.setDotsPerMeterY(int(96 / 25.4 * 1000)) + image.fill(QColor(255, 255, 255)) + painter = QPainter(image) + rc.setPainter(painter) + + try: + item.render(rc, None) + finally: + painter.end() + + self.assertTrue(self.image_check('pointtext_callout', 'pointtext_callout', image)) + if __name__ == '__main__': unittest.main() diff --git a/tests/src/python/test_qgsannotationrecttextitem.py b/tests/src/python/test_qgsannotationrecttextitem.py index 43c0787c4584..4b4ab6c76b67 100644 --- a/tests/src/python/test_qgsannotationrecttextitem.py +++ b/tests/src/python/test_qgsannotationrecttextitem.py @@ -36,7 +36,11 @@ QgsRenderContext, QgsVertexId, QgsTextFormat, - QgsMargins + QgsMargins, + QgsCallout, + QgsBalloonCallout, + QgsGeometry, + QgsSimpleLineCallout ) import unittest from qgis.testing import start_app, QgisTestCase @@ -102,7 +106,11 @@ def test_nodes(self): QgsAnnotationItemNode(QgsVertexId(0, 0, 2), QgsPointXY(30, 40), Qgis.AnnotationItemNodeType.VertexHandle), QgsAnnotationItemNode(QgsVertexId(0, 0, 3), QgsPointXY(10, 40), - Qgis.AnnotationItemNodeType.VertexHandle)]) + Qgis.AnnotationItemNodeType.VertexHandle), + QgsAnnotationItemNode(QgsVertexId(1, 0, 0), + QgsPointXY(20, 30), + Qgis.AnnotationItemNodeType.CalloutHandle) + ]) def test_transform(self): item = QgsAnnotationRectangleTextItem('my text', QgsRectangle(10, 20, 30, 40)) @@ -136,6 +144,14 @@ def test_apply_move_node_edit(self): QgsAnnotationItemEditContext()), Qgis.AnnotationItemEditOperationResult.Success) self.assertEqual(item.bounds().toString(3), '2.000,13.000 : 18.000,39.000') + # move callout handle + self.assertEqual(item.applyEditV2(QgsAnnotationItemEditOperationMoveNode('', QgsVertexId(1, 0, 0), QgsPoint(14, 13), QgsPoint(1, 3)), QgsAnnotationItemEditContext()), Qgis.AnnotationItemEditOperationResult.Success) + self.assertEqual(item.bounds().toString(3), + '2.000,13.000 : 18.000,39.000') + self.assertEqual(item.calloutAnchor().asWkt(), 'Point (1 3)') + # callout should have been automatically created + self.assertIsInstance(item.callout(), QgsCallout) + def test_apply_delete_node_edit(self): item = QgsAnnotationRectangleTextItem('my text', QgsRectangle(10, 20, 30, 40)) @@ -161,6 +177,11 @@ def test_transient_move_operation(self): ) self.assertEqual(res.representativeGeometry().asWkt(), 'Polygon ((10 18, 17 18, 17 40, 10 40, 10 18))') + # move callout handle + res = item.transientEditResultsV2(QgsAnnotationItemEditOperationMoveNode('', QgsVertexId(1, 0, 0), QgsPoint(14, 13), QgsPoint(1, 3)), QgsAnnotationItemEditContext()) + self.assertEqual(res.representativeGeometry().asWkt(), + 'Point (1 3)') + def test_transient_translate_operation(self): item = QgsAnnotationRectangleTextItem('my text', QgsRectangle(10, 20, 30, 40)) @@ -189,6 +210,8 @@ def testReadWriteXml(self): item.setFormat(format) item.setMargins(QgsMargins(1, 2, 3, 4)) item.setMarginsUnit(Qgis.RenderUnit.Points) + item.setCalloutAnchor(QgsGeometry.fromWkt('Point(1 3)')) + item.setCallout(QgsBalloonCallout()) self.assertTrue(item.writeXml(elem, doc, QgsReadWriteContext())) @@ -207,6 +230,8 @@ def testReadWriteXml(self): self.assertEqual(s2.format().size(), 37) self.assertEqual(s2.margins(), QgsMargins(1, 2, 3, 4)) self.assertEqual(s2.marginsUnit(), Qgis.RenderUnit.Points) + self.assertEqual(s2.calloutAnchor().asWkt(), 'Point (1 3)') + self.assertIsInstance(s2.callout(), QgsBalloonCallout) def testClone(self): item = QgsAnnotationRectangleTextItem('my text', QgsRectangle(10, 20, 30, 40)) @@ -222,6 +247,8 @@ def testClone(self): item.setFormat(format) item.setMargins(QgsMargins(1, 2, 3, 4)) item.setMarginsUnit(Qgis.RenderUnit.Points) + item.setCalloutAnchor(QgsGeometry.fromWkt('Point(1 3)')) + item.setCallout(QgsBalloonCallout()) s2 = item.clone() self.assertEqual(s2.bounds().toString(3), '10.000,20.000 : 30.000,40.000') @@ -236,6 +263,8 @@ def testClone(self): self.assertEqual(s2.format().size(), 37) self.assertEqual(s2.margins(), QgsMargins(1, 2, 3, 4)) self.assertEqual(s2.marginsUnit(), Qgis.RenderUnit.Points) + self.assertEqual(s2.calloutAnchor().asWkt(), 'Point (1 3)') + self.assertIsInstance(s2.callout(), QgsBalloonCallout) def testRender(self): item = QgsAnnotationRectangleTextItem('my text', QgsRectangle(12, 13, 14, 15)) @@ -380,6 +409,44 @@ def testRenderBackgroundFrame(self): self.assertTrue(self.image_check('recttext_background_frame', 'recttext_background_frame', image)) + def testRenderCallout(self): + item = QgsAnnotationRectangleTextItem('my text', QgsRectangle(12, 13, 14, 15)) + item.setMargins(QgsMargins(1, 0.5, 1, 0)) + item.setFrameSymbol(QgsFillSymbol.createSimple( + {'color': '0,0,0,0', 'outline_color': 'black', 'outline_width': 2})) + callout = QgsSimpleLineCallout() + callout.lineSymbol().setWidth(1) + item.setCallout(callout) + item.setCalloutAnchor(QgsGeometry.fromWkt('Point(11 12)')) + + format = QgsTextFormat.fromQFont(getTestFont('Bold')) + format.setColor(QColor(255, 0, 0)) + format.setOpacity(150 / 255) + format.setSize(20) + item.setFormat(format) + + settings = QgsMapSettings() + settings.setDestinationCrs(QgsCoordinateReferenceSystem('EPSG:4326')) + settings.setExtent(QgsRectangle(10, 8, 18, 16)) + settings.setOutputSize(QSize(300, 300)) + + settings.setFlag(QgsMapSettings.Flag.Antialiasing, False) + + rc = QgsRenderContext.fromMapSettings(settings) + image = QImage(200, 200, QImage.Format.Format_ARGB32) + image.setDotsPerMeterX(int(96 / 25.4 * 1000)) + image.setDotsPerMeterY(int(96 / 25.4 * 1000)) + image.fill(QColor(255, 255, 255)) + painter = QPainter(image) + rc.setPainter(painter) + + try: + item.render(rc, None) + finally: + painter.end() + + self.assertTrue(self.image_check('recttext_render_callout', 'recttext_render_callout', image)) + if __name__ == '__main__': unittest.main() diff --git a/tests/testdata/control_images/annotation_layer/expected_picture_callout/expected_picture_callout.png b/tests/testdata/control_images/annotation_layer/expected_picture_callout/expected_picture_callout.png new file mode 100644 index 000000000000..3dd2738e9e3c Binary files /dev/null and b/tests/testdata/control_images/annotation_layer/expected_picture_callout/expected_picture_callout.png differ diff --git a/tests/testdata/control_images/annotation_layer/expected_picture_fixed_size_callout/expected_picture_fixed_size_callout.png b/tests/testdata/control_images/annotation_layer/expected_picture_fixed_size_callout/expected_picture_fixed_size_callout.png new file mode 100644 index 000000000000..7b24675de4fb Binary files /dev/null and b/tests/testdata/control_images/annotation_layer/expected_picture_fixed_size_callout/expected_picture_fixed_size_callout.png differ diff --git a/tests/testdata/control_images/annotation_layer/expected_picture_fixed_size_callout/expected_picture_fixed_size_callout_mask.png b/tests/testdata/control_images/annotation_layer/expected_picture_fixed_size_callout/expected_picture_fixed_size_callout_mask.png new file mode 100644 index 000000000000..1134079b6182 Binary files /dev/null and b/tests/testdata/control_images/annotation_layer/expected_picture_fixed_size_callout/expected_picture_fixed_size_callout_mask.png differ diff --git a/tests/testdata/control_images/annotation_layer/expected_pointtext_callout/expected_pointtext_callout.png b/tests/testdata/control_images/annotation_layer/expected_pointtext_callout/expected_pointtext_callout.png new file mode 100644 index 000000000000..910d4a4fd03f Binary files /dev/null and b/tests/testdata/control_images/annotation_layer/expected_pointtext_callout/expected_pointtext_callout.png differ diff --git a/tests/testdata/control_images/annotation_layer/expected_recttext_render_callout/expected_recttext_render_callout.png b/tests/testdata/control_images/annotation_layer/expected_recttext_render_callout/expected_recttext_render_callout.png new file mode 100644 index 000000000000..acdad5b7df93 Binary files /dev/null and b/tests/testdata/control_images/annotation_layer/expected_recttext_render_callout/expected_recttext_render_callout.png differ diff --git a/tests/testdata/control_images/annotation_layer/expected_recttext_render_callout/expected_recttext_render_callout_mask.png b/tests/testdata/control_images/annotation_layer/expected_recttext_render_callout/expected_recttext_render_callout_mask.png new file mode 100644 index 000000000000..902d4278ed2e Binary files /dev/null and b/tests/testdata/control_images/annotation_layer/expected_recttext_render_callout/expected_recttext_render_callout_mask.png differ