diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index 94d81db28..2a46f4e8e 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -581,6 +581,7 @@ set(QET_SRC_FILES
${QET_DIR}/sources/qetgraphicsitem/crossrefitem.h
${QET_DIR}/sources/qetgraphicsitem/diagramimageitem.cpp
${QET_DIR}/sources/qetgraphicsitem/diagramimageitem.h
+ ${QET_DIR}/sources/qetgraphicsitem/cropgeometry.h
${QET_DIR}/sources/qetgraphicsitem/diagramtextitem.cpp
${QET_DIR}/sources/qetgraphicsitem/diagramtextitem.h
${QET_DIR}/sources/qetgraphicsitem/dynamicelementtextitem.cpp
diff --git a/lang/qet_fr.ts b/lang/qet_fr.ts
index 13b72e0d8..ea700c2c4 100644
--- a/lang/qet_fr.ts
+++ b/lang/qet_fr.ts
@@ -2212,11 +2212,21 @@ Continuer ?
rotate/skew
pivoter/incliner
+
+
+ crop
+ rogner
+
Click: mode %1
Cliquer : mode %1
+
+
+ Crop: drag the handles or the frame -- Enter, double-click or click elsewhere: apply; Escape: cancel
+ Rogner : glisser les poignées ou le cadre -- Entrée, double-clic ou clic à côté : appliquer ; Échap : annuler
+
Drag a corner/edge: resize (Ctrl = from center, Shift = keep proportions)
@@ -2257,6 +2267,11 @@ Continuer ?
Drag: move the rotation center
Glisser : déplacer le centre de rotation
+
+
+ Drag: crop (Enter = apply, Escape = cancel)
+ Glisser : rogner (Entrée = appliquer, Échap = annuler)
+
Resize an image
@@ -2359,11 +2374,21 @@ Continuer ?
Transparent color...
Couleur transparente...
+
+
+ Crop on the folio
+ Rogner sur le folio
+
Crop...
Rogner...
+
+
+ Reset crop
+ Réinitialiser le rognage
+
Horizontal mirror
@@ -2379,6 +2404,11 @@ Continuer ?
Restore proportions
Restaurer les proportions
+
+
+ Properties...
+ Propriétés...
+
Select an image ...
diff --git a/sources/qetgraphicsitem/cropgeometry.h b/sources/qetgraphicsitem/cropgeometry.h
new file mode 100644
index 000000000..b72b46b1b
--- /dev/null
+++ b/sources/qetgraphicsitem/cropgeometry.h
@@ -0,0 +1,97 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#ifndef CROPGEOMETRY_H
+#define CROPGEOMETRY_H
+
+#include
+#include
+#include
+
+#include
+#include
+
+/**
+ The geometry of cropping a picture directly on the folio, in the
+ picture's own pixels. Free of any item so that it can be tested on
+ its own.
+*/
+namespace CropGeometry
+{
+ /// Which edges of the crop frame a handle moves.
+ struct Edges
+ {
+ bool left = false;
+ bool top = false;
+ bool right = false;
+ bool bottom = false;
+ };
+
+ /**
+ Edges moved by the handle at @a handle on a frame of @a size, from
+ where that handle sits: on the left side it moves the left edge,
+ in the middle of a side only that side's edge.
+ */
+ inline Edges edgesForHandle(const QPointF &handle, const QSizeF &size)
+ {
+ Edges e;
+ e.left = qFuzzyIsNull(handle.x());
+ e.right = qFuzzyCompare(handle.x(), size.width());
+ e.top = qFuzzyIsNull(handle.y());
+ e.bottom = qFuzzyCompare(handle.y(), size.height());
+ return e;
+ }
+
+ /**
+ @a crop with the @a edges moved to @a point, kept inside @a bounds
+ and never smaller than @a minimum on either side: an edge stops
+ at the opposite one instead of crossing it.
+ */
+ inline QRectF dragEdges(const QRectF &crop, Edges edges, const QPointF &point,
+ const QRectF &bounds, qreal minimum)
+ {
+ const qreal x = std::clamp(point.x(), bounds.left(), bounds.right());
+ const qreal y = std::clamp(point.y(), bounds.top(), bounds.bottom());
+ QRectF r = crop;
+ if (edges.left) r.setLeft(std::min(x, crop.right() - minimum));
+ if (edges.right) r.setRight(std::max(x, crop.left() + minimum));
+ if (edges.top) r.setTop(std::min(y, crop.bottom() - minimum));
+ if (edges.bottom) r.setBottom(std::max(y, crop.top() + minimum));
+ return r;
+ }
+
+ /// @a crop moved by @a delta, but no further than @a bounds allow.
+ inline QRectF moveWithin(const QRectF &crop, const QPointF &delta, const QRectF &bounds)
+ {
+ QRectF r = crop.translated(delta);
+ r.moveLeft(std::clamp(r.left(), bounds.left(), bounds.right() - r.width()));
+ r.moveTop(std::clamp(r.top(), bounds.top(), bounds.bottom() - r.height()));
+ return r;
+ }
+
+ /// The whole-pixel crop rectangle closest to @a crop.
+ inline QRect toPixels(const QRectF &crop)
+ {
+ const int left = int(std::lround(crop.left()));
+ const int top = int(std::lround(crop.top()));
+ return QRect(left, top,
+ int(std::lround(crop.right())) - left,
+ int(std::lround(crop.bottom())) - top);
+ }
+}
+
+#endif // CROPGEOMETRY_H
diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp
index 843657b54..a952c4851 100644
--- a/sources/qetgraphicsitem/diagramimageitem.cpp
+++ b/sources/qetgraphicsitem/diagramimageitem.cpp
@@ -17,6 +17,7 @@
*/
#include "diagramimageitem.h"
#include "../darkimagerendering.h"
+#include "cropgeometry.h"
#include "../shownkinds.h"
#include "../PropertiesEditor/propertieseditordialog.h"
@@ -41,6 +42,9 @@
#include
#include
#include
+#include
+#include
+#include
#include
#include
#include
@@ -99,6 +103,70 @@ DiagramImageItem::~DiagramImageItem()
@param widget the QWidget where we draw the pixmap
*/
void DiagramImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
+ if (isCropping())
+ {
+ // The whole original, dimmed, with the region being kept drawn
+ // over it at full strength and outlined. In a dark scene this is
+ // a picture like any other: kept out of the scene's inversion
+ // unless the picture adapts to the dark theme, as
+ // DarkImageRendering::paintPixmap() does for the plain picture.
+ Q_UNUSED(option); Q_UNUSED(widget);
+ const QPointF origin = -QPointF(m_crop_rect.topLeft());
+ const QRectF previewRect(origin, QSizeF(m_crop_preview.size()));
+ DarkImageRendering::Context *context = DarkImageRendering::current;
+ const bool boundary = context && context->painter == painter
+ && m_adapt_to_dark_theme != context->invertScene;
+ if (boundary) context->flush(context->invertScene, QRect());
+ painter -> save();
+ painter -> setOpacity(0.3);
+ painter -> drawPixmap(origin, m_crop_preview);
+ painter -> setOpacity(1.0);
+ painter -> drawPixmap(cropFrameLocal(), m_crop_preview, m_pending_crop);
+ const QRectF f = cropFrameLocal();
+ QPen frame(Qt::black);
+ frame.setCosmetic(true);
+ frame.setWidth(1);
+ painter -> setPen(frame);
+ painter -> setBrush(Qt::NoBrush);
+ painter -> drawRect(f);
+
+ // Crop bars, as photo editors draw them: an L at each corner and a
+ // short bar in the middle of each side, black on a white outline
+ // so they show on dark pictures too, and the same size on screen
+ // whatever the zoom or the picture's own scale.
+ const QTransform t = painter -> worldTransform();
+ const qreal px = 1.0 / qMax(1e-6, qSqrt(qAbs(t.m11() * t.m22() - t.m12() * t.m21())));
+ const qreal corner = qMin(18 * px, qMin(f.width(), f.height()) / 2);
+ const qreal side = qMin(10 * px, qMin(f.width(), f.height()) / 4);
+ QPainterPath bars;
+ auto l = [&bars](QPointF a, QPointF b, QPointF c) {
+ bars.moveTo(a); bars.lineTo(b); bars.lineTo(c);
+ };
+ l(f.topLeft() + QPointF(0, corner), f.topLeft(), f.topLeft() + QPointF(corner, 0));
+ l(f.topRight() - QPointF(corner, 0), f.topRight(), f.topRight() + QPointF(0, corner));
+ l(f.bottomRight() - QPointF(0, corner), f.bottomRight(), f.bottomRight() - QPointF(corner, 0));
+ l(f.bottomLeft() + QPointF(corner, 0), f.bottomLeft(), f.bottomLeft() - QPointF(0, corner));
+ const QPointF c = f.center();
+ bars.moveTo(c.x() - side, f.top()); bars.lineTo(c.x() + side, f.top());
+ bars.moveTo(c.x() - side, f.bottom()); bars.lineTo(c.x() + side, f.bottom());
+ bars.moveTo(f.left(), c.y() - side); bars.lineTo(f.left(), c.y() + side);
+ bars.moveTo(f.right(), c.y() - side); bars.lineTo(f.right(), c.y() + side);
+ painter -> setRenderHint(QPainter::Antialiasing, true);
+ QPen outline(Qt::white, 7, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin);
+ outline.setCosmetic(true);
+ painter -> setPen(outline);
+ painter -> drawPath(bars);
+ QPen bar(Qt::black, 4, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin);
+ bar.setCosmetic(true);
+ painter -> setPen(bar);
+ painter -> drawPath(bars);
+ painter -> restore();
+ if (boundary)
+ context->flush(m_adapt_to_dark_theme, painter->worldTransform().mapRect(previewRect)
+ .toAlignedRect().adjusted(-2, -2, 2, 2));
+ return;
+ }
+
DarkImageRendering::paintPixmap(painter, pixmap_, m_adapt_to_dark_theme);
Q_UNUSED(option); Q_UNUSED(widget);
@@ -213,6 +281,11 @@ void DiagramImageItem::setPixmap(const QPixmap &pixmap) {
*/
void DiagramImageItem::setImageSource(const ImageSource &source)
{
+ // An undo or redo that changes the picture under a crop being edited
+ // ends that crop: its frame and its preview were made for the source
+ // that is replaced here. Nothing is applied, since this may run
+ // inside an undo command.
+ finishCropMode(false);
m_base_pixmap = source.base;
m_crop_rect = source.crop;
m_transparent_colors = source.colors;
@@ -461,6 +534,11 @@ QPointF DiagramImageItem::handlePosition(int index) const
{
const qreal w = pixmap_.width(), h = pixmap_.height();
const HandleRole role = m_handleRoles.at(index);
+ if (role == HandleRole::CropEdge)
+ {
+ const QRectF frame = cropFrameLocal();
+ return frame.topLeft() + handleNaturalPosition(index, frame.width(), frame.height());
+ }
if (role == HandleRole::Resize)
return handleNaturalPosition(index, w, h);
if (role == HandleRole::Rotate)
@@ -479,6 +557,24 @@ QPointF DiagramImageItem::handlePosition(int index) const
*/
void DiagramImageItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
+ if (isCropping())
+ {
+ // Inside the crop frame: move it. On the dimmed rest: done.
+ if (event->button() == Qt::LeftButton)
+ {
+ if (cropFrameLocal().contains(event->pos()))
+ {
+ m_crop_moving = true;
+ m_crop_move_start = event->pos();
+ m_crop_move_origin = m_pending_crop;
+ }
+ else
+ finishCropMode(true);
+ }
+ event->accept();
+ return;
+ }
+
const bool wasAlreadySelected = isSelected();
event->ignore();
QetGraphicsItem::mousePressEvent(event);
@@ -491,12 +587,67 @@ void DiagramImageItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
}
}
+/**
+ @brief DiagramImageItem::mouseMoveEvent
+ In crop mode, a drag inside the crop frame moves it over the
+ original; the picture itself stays where it is.
+*/
+void DiagramImageItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
+{
+ if (isCropping())
+ {
+ if (m_crop_moving)
+ {
+ m_pending_crop = CropGeometry::moveWithin(m_crop_move_origin,
+ event->pos() - m_crop_move_start, QRectF(m_base_pixmap.rect()));
+ repositionHandles();
+ update();
+ }
+ event->accept();
+ return;
+ }
+ QetGraphicsItem::mouseMoveEvent(event);
+}
+
+void DiagramImageItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
+{
+ if (isCropping())
+ {
+ m_crop_moving = false;
+ event->accept();
+ return;
+ }
+ QetGraphicsItem::mouseReleaseEvent(event);
+}
+
+/**
+ @brief DiagramImageItem::mouseDoubleClickEvent
+ Double-click crops the picture directly on the folio, and applies the
+ crop when it is already being edited. The properties stay in the
+ context menu.
+*/
+void DiagramImageItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
+{
+ if (event->button() != Qt::LeftButton)
+ {
+ QetGraphicsItem::mouseDoubleClickEvent(event);
+ return;
+ }
+ if (isCropping())
+ finishCropMode(true);
+ else
+ enterCropMode();
+ event->accept();
+}
+
/**
@brief DiagramImageItem::toggleHandleMode
*/
void DiagramImageItem::toggleHandleMode()
{
prepareGeometryChange();
+ if (isCropping())
+ return;
m_handleMode = (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size;
rebuildHandles();
refreshInteractionHints();
@@ -517,7 +668,13 @@ DiagramImageItem::HandleMode DiagramImageItem::nextHandleMode() const
*/
QString DiagramImageItem::handleModeLabel(HandleMode mode)
{
- return (mode == HandleMode::Size) ? tr("resize") : tr("rotate/skew");
+ switch (mode)
+ {
+ case HandleMode::Size: return tr("resize");
+ case HandleMode::RotateSkew: return tr("rotate/skew");
+ case HandleMode::Crop: return tr("crop");
+ }
+ return QString();
}
/**
@@ -570,6 +727,8 @@ void DiagramImageItem::refreshInteractionHints()
*/
QString DiagramImageItem::currentModeStatusHint() const
{
+ if (isCropping())
+ return tr("Crop: drag the handles or the frame -- Enter, double-click or click elsewhere: apply; Escape: cancel");
QString hint = (m_handleMode == HandleMode::Size)
? tr("Drag a corner/edge: resize (Ctrl = from center, Shift = keep proportions)")
: tr("Drag a corner: rotate (Shift = 15° steps); drag an edge: skew (Shift = 15° steps); red point: move the rotation center");
@@ -636,6 +795,7 @@ QColor DiagramImageItem::colorForHandleRole(HandleRole role)
case HandleRole::Rotate: return Qt::darkGreen;
case HandleRole::SkewEdge: return QColor(255, 140, 0);
case HandleRole::Pivot: return Qt::red;
+ case HandleRole::CropEdge: return Qt::transparent; // paint() draws crop bars instead
}
return Qt::blue;
}
@@ -648,6 +808,7 @@ QString DiagramImageItem::hintForHandleRole(HandleRole role)
case HandleRole::Rotate: return tr("Drag: rotate (Shift = 15° steps)");
case HandleRole::SkewEdge: return tr("Drag: skew (Shift = 15° steps)");
case HandleRole::Pivot: return tr("Drag: move the rotation center");
+ case HandleRole::CropEdge: return tr("Drag: crop (Enter = apply, Escape = cancel)");
}
return QString();
}
@@ -656,7 +817,12 @@ void DiagramImageItem::rebuildHandles()
{
clearHandles();
- if (m_handleMode == HandleMode::Size)
+ if (m_handleMode == HandleMode::Crop)
+ {
+ for (int i = 0; i < 8; ++i)
+ m_handleRoles << HandleRole::CropEdge;
+ }
+ else if (m_handleMode == HandleMode::Size)
{
for (int i = 0; i < 8; ++i)
m_handleRoles << HandleRole::Resize;
@@ -684,6 +850,13 @@ void DiagramImageItem::rebuildHandles()
h->setColor(colorForHandleRole(m_handleRoles.at(i)));
h->setToolTip(hintForHandleRole(m_handleRoles.at(i)));
h->setAcceptHoverEvents(true);
+ if (m_handleRoles.at(i) == HandleRole::CropEdge)
+ {
+ static const Qt::CursorShape cursors[8] = {
+ Qt::SizeFDiagCursor, Qt::SizeVerCursor, Qt::SizeBDiagCursor, Qt::SizeHorCursor,
+ Qt::SizeHorCursor, Qt::SizeBDiagCursor, Qt::SizeVerCursor, Qt::SizeFDiagCursor};
+ h->setCursor(cursors[i]);
+ }
scene()->addItem(h);
h->installSceneEventFilter(this);
}
@@ -849,7 +1022,9 @@ void DiagramImageItem::handlerMouseMoveEvent(int index, QGraphicsSceneMouseEvent
{
const HandleRole role = m_handleRoles.at(index);
- if (role == HandleRole::Resize)
+ if (role == HandleRole::CropEdge)
+ dragCropHandle(index, event->scenePos());
+ else if (role == HandleRole::Resize)
dragResize(index, mapFromScene(event->scenePos()), event->modifiers());
else if (role == HandleRole::Rotate)
dragRotateHandle(index, event->scenePos(), event->modifiers()); // index IS the corner slot (0-3): Rotate handles are always first
@@ -945,6 +1120,9 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
else
m_pivotIsCustom = m_original_pivotIsCustom; // dragged back to where it was: nothing to undo
break;
+
+ case HandleRole::CropEdge:
+ break; // nothing is undone until the crop is applied
}
if (undo)
@@ -981,11 +1159,25 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
/**
@brief DiagramImageItem::keyPressEvent
The view sends keys here only while one of the picture's handles is
- dragged (see handlerMousePressEvent()): Escape cancels that drag. Any
- other key is left to the view.
+ dragged (see handlerMousePressEvent()) or while it is cropped (see
+ enterCropMode()). While cropping, Enter applies the crop and Escape
+ drops it; otherwise Escape cancels the drag. Any other key is left to
+ the view.
*/
void DiagramImageItem::keyPressEvent(QKeyEvent *event)
{
+ if (isCropping())
+ {
+ const int key = event->key();
+ if (key == Qt::Key_Escape || key == Qt::Key_Return || key == Qt::Key_Enter)
+ {
+ if (m_vector_index != -1)
+ m_drag_cancelled = true; // swallow the rest of a handle drag
+ finishCropMode(key != Qt::Key_Escape);
+ event->accept();
+ return;
+ }
+ }
if (event->key() == Qt::Key_Escape && m_vector_index != -1)
{
cancelHandleDrag();
@@ -1003,6 +1195,16 @@ void DiagramImageItem::keyPressEvent(QKeyEvent *event)
void DiagramImageItem::endHandleDrag()
{
m_vector_index = -1;
+ if (!isCropping()) // crop mode keeps the keys until it ends
+ releaseKeyboardItem();
+}
+
+/**
+ @brief DiagramImageItem::releaseKeyboardItem
+ The view's shortcuts get the keys again.
+*/
+void DiagramImageItem::releaseKeyboardItem()
+{
if (diagram() && diagram()->keyboardItem() == this)
diagram()->setKeyboardItem(nullptr);
}
@@ -1410,6 +1612,9 @@ QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant
rebuildHandles();
else
{
+ // Clicking elsewhere on the folio applies a crop in progress.
+ if (isCropping())
+ finishCropMode(true);
prepareGeometryChange();
clearHandles();
m_handleMode = HandleMode::Size;
@@ -1426,6 +1631,13 @@ QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant
if (!m_deferHandleReposition)
repositionHandles();
}
+ else if (change == ItemSceneChange)
+ {
+ // Leaving the folio (undoing the picture's insertion, for one)
+ // drops a crop in progress while diagram() is still there to
+ // give the keys back to the view.
+ finishCropMode(false);
+ }
else if (change == ItemSceneHasChanged)
{
if (!scene())
@@ -1467,6 +1679,20 @@ QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect
*/
QRectF DiagramImageItem::boundingRect() const
{
+ if (isCropping())
+ {
+ // Room for the crop bars, which are drawn a few screen pixels
+ // wide over the edge of the original.
+ qreal zoom = 1.0;
+ if (scene() && !scene()->views().isEmpty())
+ {
+ const QTransform t = scene()->views().first()->transform() * sceneTransform();
+ zoom = qSqrt(qAbs(t.m11() * t.m22() - t.m12() * t.m21()));
+ }
+ const qreal m = 8.0 / qMax(1e-6, zoom);
+ return imageRect().united(QRectF(-QPointF(m_crop_rect.topLeft()), m_base_pixmap.size()))
+ .adjusted(-m, -m, m, m);
+ }
if (m_label.isEmpty())
return imageRect();
return imageRect().united(labelRect());
@@ -1877,10 +2103,18 @@ void DiagramImageItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
transparentColor->setIcon(QET::Icons::EditOpacity);
connect(transparentColor, &QAction::triggered, this, &DiagramImageItem::setTransparentColor);
+ QAction *cropHere = menu.data()->addAction(tr("Crop on the folio"));
+ cropHere->setIcon(QET::Icons::TransformCrop);
+ connect(cropHere, &QAction::triggered, this, &DiagramImageItem::enterCropMode);
+
QAction *cropAction = menu.data()->addAction(tr("Crop..."));
cropAction->setIcon(QET::Icons::TransformCrop);
connect(cropAction, &QAction::triggered, this, &DiagramImageItem::crop);
+ QAction *resetCropAction = menu.data()->addAction(tr("Reset crop"));
+ resetCropAction->setEnabled(m_crop_rect != m_base_pixmap.rect());
+ connect(resetCropAction, &QAction::triggered, this, &DiagramImageItem::resetCrop);
+
QAction *mirrorH = menu.data()->addAction(tr("Horizontal mirror"));
mirrorH->setIcon(QET::Icons::ImageFlipHorizontal);
QAction *mirrorV = menu.data()->addAction(tr("Vertical mirror"));
@@ -1891,9 +2125,10 @@ void DiagramImageItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
QAction *restoreRatio = menu.data()->addAction(tr("Restore proportions"));
connect(restoreRatio, &QAction::triggered, this, &DiagramImageItem::restoreAspectRatio);
- // menu.data()->addSeparator();
- // QAction *properties = menu.data()->addAction(tr("Propriétés..."));
- // connect(properties, &QAction::triggered, this, &DiagramImageItem::editProperty);
+ // Double-click crops now, so the properties are opened from here.
+ menu.data()->addSeparator();
+ QAction *properties = menu.data()->addAction(tr("Properties..."));
+ connect(properties, &QAction::triggered, this, &DiagramImageItem::editProperty);
menu.data()->addSeparator();
menu.data()->addActions(d_view->contextMenuActions());
@@ -2063,6 +2298,94 @@ void DiagramImageItem::crop()
applyCrop(dialog.cropRect());
}
+/**
+ @brief DiagramImageItem::enterCropMode
+ Crop the picture directly on the folio: the whole original is shown,
+ dimmed outside the region kept, and handles move the crop's edges.
+ Nothing changes until finishCropMode() applies it.
+*/
+void DiagramImageItem::enterCropMode()
+{
+ if (!diagram() || diagram()->isReadOnly() || m_base_pixmap.isNull() || isCropping())
+ return;
+ setSelected(true);
+ prepareGeometryChange();
+ m_pending_crop = QRectF(m_crop_rect);
+ m_crop_preview = computeDisplayPixmap(m_base_pixmap, m_base_pixmap.rect(), m_transparent_colors);
+ m_handleMode = HandleMode::Crop;
+ rebuildHandles();
+ // Enter and Escape end the crop: the view sends keys here first, before
+ // its own Enter (repeat the last command) and Escape (clear the
+ // selection, which would apply the crop).
+ diagram()->setKeyboardItem(this);
+ refreshInteractionHints();
+ showStatusHint(currentModeStatusHint());
+ update();
+}
+
+/**
+ @brief DiagramImageItem::finishCropMode
+ Leave crop mode, applying the crop when @a apply is true -- one undo
+ step, see applyCrop() -- and dropping it otherwise.
+*/
+void DiagramImageItem::finishCropMode(bool apply)
+{
+ if (!isCropping())
+ return;
+ prepareGeometryChange();
+ const QRect crop = CropGeometry::toPixels(m_pending_crop);
+ m_handleMode = HandleMode::Size;
+ releaseKeyboardItem();
+ m_crop_moving = false;
+ m_crop_preview = QPixmap();
+ if (isSelected())
+ rebuildHandles();
+ else
+ clearHandles();
+ clearStatusHint();
+ refreshInteractionHints();
+ update();
+ if (apply)
+ applyCrop(crop);
+}
+
+/**
+ @brief DiagramImageItem::resetCrop
+ Show the whole original again, as one undoable step.
+*/
+void DiagramImageItem::resetCrop()
+{
+ finishCropMode(false);
+ applyCrop(m_base_pixmap.rect());
+}
+
+/**
+ @brief DiagramImageItem::cropFrameLocal
+ @return the crop being edited, in the item's own coordinates, where
+ the current crop's top-left corner is (0, 0).
+*/
+QRectF DiagramImageItem::cropFrameLocal() const
+{
+ return m_pending_crop.translated(-QPointF(m_crop_rect.topLeft()));
+}
+
+/**
+ @brief DiagramImageItem::dragCropHandle
+ Move the crop edges held by handle @a index to @a scenePos, mapped
+ through the picture's transform, so a rotated or scaled picture is
+ cropped along its own sides.
+*/
+void DiagramImageItem::dragCropHandle(int index, const QPointF &scenePos)
+{
+ const QPointF basePoint = mapFromScene(scenePos) + QPointF(m_crop_rect.topLeft());
+ const CropGeometry::Edges edges = CropGeometry::edgesForHandle(
+ handleNaturalPosition(index, 2, 2), QSizeF(2, 2));
+ m_pending_crop = CropGeometry::dragEdges(m_pending_crop, edges, basePoint,
+ QRectF(m_base_pixmap.rect()), 4.0);
+ repositionHandles();
+ update();
+}
+
/**
@brief DiagramImageItem::applyCrop
Show @a cropRect of the original (in the original's own pixels),
diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h
index 61ede8ccb..6e0f308f8 100644
--- a/sources/qetgraphicsitem/diagramimageitem.h
+++ b/sources/qetgraphicsitem/diagramimageitem.h
@@ -88,6 +88,10 @@ class DiagramImageItem : public QetGraphicsItem {
void setImageSourceVariant(const QVariant &source);
QRect cropRect() const { return m_crop_rect; }
bool applyCrop(const QRect &cropRect);
+ bool isCropping() const { return m_handleMode == HandleMode::Crop; }
+ void enterCropMode();
+ void finishCropMode(bool apply);
+ void resetCrop();
// attributes
public:
@@ -103,8 +107,8 @@ class DiagramImageItem : public QetGraphicsItem {
// not shared code, since the two classes' broader handle roles
// differ too much (no path/polygon/arc concepts here) for sharing
// the dispatcher itself to be worth an indirect abstraction.
- enum class HandleMode { Size, RotateSkew };
- enum class HandleRole { Resize, Rotate, SkewEdge, Pivot };
+ enum class HandleMode { Size, RotateSkew, Crop };
+ enum class HandleRole { Resize, Rotate, SkewEdge, Pivot, CropEdge };
// methods
public:
@@ -171,6 +175,9 @@ class DiagramImageItem : public QetGraphicsItem {
void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override;
void contextMenuEvent(QGraphicsSceneContextMenuEvent *event) override;
void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
+ void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
+ void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
+ void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;
void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override;
bool sceneEventFilter(QGraphicsItem *watched, QEvent *event) override;
@@ -206,6 +213,7 @@ class DiagramImageItem : public QetGraphicsItem {
void handlerMouseReleaseEvent(int index);
void cancelHandleDrag();
void endHandleDrag();
+ void releaseKeyboardItem();
void dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods);
void dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods);
void dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods);
@@ -268,6 +276,16 @@ class DiagramImageItem : public QetGraphicsItem {
int m_vector_index = -1;
QPointF m_original_pos; // scene position at the start of a resize/rotate/pivot drag, for Escape-to-cancel
bool m_drag_cancelled = false; // Escape pressed: ignore the rest of this mouse gesture
+ // Crop mode: the crop being edited, in the original's pixels; the
+ // whole original, colour-keyed, shown dimmed around it; and the
+ // state of a drag that moves the crop window.
+ QRectF m_pending_crop;
+ QPixmap m_crop_preview;
+ bool m_crop_moving = false;
+ QPointF m_crop_move_start;
+ QRectF m_crop_move_origin;
+ QRectF cropFrameLocal() const;
+ void dragCropHandle(int index, const QPointF &scenePos);
ShapeTransform m_original_transform;
bool m_original_pivotIsCustom = false;
bool m_deferHandleReposition = false; // see setPivot()'s comment
diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt
index 3572ed416..1616e484e 100644
--- a/tests/qttest/CMakeLists.txt
+++ b/tests/qttest/CMakeLists.txt
@@ -1118,6 +1118,15 @@ add_test(NAME tst_diagramcontext COMMAND tst_diagramcontext)
target_include_directories(tst_diagramcontext PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_diagramcontext PRIVATE Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml)
+# Cropping a picture on the folio: which edges a handle moves, how far,
+# and moving the crop window over the original (CropGeometry).
+add_executable(
+ tst_cropgeometry
+ tst_cropgeometry.cpp)
+add_test(NAME tst_cropgeometry COMMAND tst_cropgeometry)
+target_include_directories(tst_cropgeometry PRIVATE ${QET_DIR}/sources)
+target_link_libraries(tst_cropgeometry PRIVATE Qt::Test Qt::Core)
+
# XRefProperties: an empty or unknown cross-reference position, as older
# versions saved it, reads as the default instead of drawing the
# cross-reference over the element's label (issue #1238).
diff --git a/tests/qttest/tst_cropgeometry.cpp b/tests/qttest/tst_cropgeometry.cpp
new file mode 100644
index 000000000..3ee7217fa
--- /dev/null
+++ b/tests/qttest/tst_cropgeometry.cpp
@@ -0,0 +1,107 @@
+/*
+ Copyright 2006-2026 The QElectroTech Team
+ This file is part of QElectroTech.
+
+ QElectroTech is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 2 of the License, or
+ (at your option) any later version.
+
+ QElectroTech is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with QElectroTech. If not, see .
+*/
+#include "qetgraphicsitem/cropgeometry.h"
+
+#include
+
+/**
+ Cropping a picture on the folio: which edges a handle moves, how far
+ they may go, and moving the crop window over the original.
+*/
+class tst_cropgeometry : public QObject
+{
+ Q_OBJECT
+
+ const QRectF m_bounds{0, 0, 400, 300}; // the original, in its pixels
+ const QRectF m_crop{100, 50, 200, 100};
+
+private slots:
+ void handlesMoveTheEdgesTheySitOn_data()
+ {
+ QTest::addColumn("handle");
+ QTest::addColumn("left");
+ QTest::addColumn("top");
+ QTest::addColumn("right");
+ QTest::addColumn("bottom");
+ QTest::newRow("top-left corner") << QPointF(0, 0) << true << true << false << false;
+ QTest::newRow("top edge") << QPointF(1, 0) << false << true << false << false;
+ QTest::newRow("right edge") << QPointF(2, 1) << false << false << true << false;
+ QTest::newRow("bottom-right corner") << QPointF(2, 2) << false << false << true << true;
+ }
+
+ void handlesMoveTheEdgesTheySitOn()
+ {
+ QFETCH(QPointF, handle);
+ QFETCH(bool, left);
+ QFETCH(bool, top);
+ QFETCH(bool, right);
+ QFETCH(bool, bottom);
+ const CropGeometry::Edges e = CropGeometry::edgesForHandle(handle, QSizeF(2, 2));
+ QCOMPARE(e.left, left);
+ QCOMPARE(e.top, top);
+ QCOMPARE(e.right, right);
+ QCOMPARE(e.bottom, bottom);
+ }
+
+ void aCornerMovesTwoEdges()
+ {
+ CropGeometry::Edges e;
+ e.left = e.top = true;
+ QCOMPARE(CropGeometry::dragEdges(m_crop, e, QPointF(120, 60), m_bounds, 4),
+ QRectF(QPointF(120, 60), m_crop.bottomRight()));
+ }
+
+ void edgesStayOnTheOriginal()
+ {
+ CropGeometry::Edges e;
+ e.right = e.bottom = true;
+ QCOMPARE(CropGeometry::dragEdges(m_crop, e, QPointF(900, 900), m_bounds, 4),
+ QRectF(m_crop.topLeft(), m_bounds.bottomRight()));
+ CropGeometry::Edges l;
+ l.left = true;
+ QCOMPARE(CropGeometry::dragEdges(m_crop, l, QPointF(-50, 0), m_bounds, 4).left(), 0.0);
+ }
+
+ // Dragged past the opposite edge, an edge stops a minimum away
+ // instead of turning the frame inside out.
+ void edgesNeverCross()
+ {
+ CropGeometry::Edges e;
+ e.left = true;
+ const QRectF r = CropGeometry::dragEdges(m_crop, e, QPointF(380, 80), m_bounds, 4);
+ QCOMPARE(r.left(), m_crop.right() - 4);
+ QCOMPARE(r.width(), 4.0);
+ }
+
+ void theWindowMovesWithinTheOriginal()
+ {
+ QCOMPARE(CropGeometry::moveWithin(m_crop, QPointF(30, -20), m_bounds),
+ m_crop.translated(30, -20));
+ QCOMPARE(CropGeometry::moveWithin(m_crop, QPointF(500, -500), m_bounds),
+ QRectF(QPointF(200, 0), m_crop.size()));
+ }
+
+ void cropsLandOnWholePixels()
+ {
+ QCOMPARE(CropGeometry::toPixels(QRectF(10.4, 9.6, 20.2, 30.3)), QRect(10, 10, 21, 30));
+ QCOMPARE(CropGeometry::toPixels(m_bounds), m_bounds.toRect());
+ }
+};
+
+QTEST_GUILESS_MAIN(tst_cropgeometry)
+#include "tst_cropgeometry.moc"