From d86f2d5461da049b52e127f3e9d3ebce4042ede5 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sun, 4 Oct 2026 19:59:26 +0200 Subject: [PATCH] Crop a picture directly on the folio Double-clicking a picture, or "Crop on the folio" in its context menu, crops it in place: the whole original is shown, dimmed outside the region kept, and eight handles move the crop's edges; a drag inside the frame moves the crop window over the original. Enter, a double-click or a click elsewhere applies the crop as one undo step, keeping the kept region where it is on the folio; Escape drops it. Rotated and scaled pictures are cropped along their own sides. While cropping, the picture is the folio's keyboardItem (see the Escape commit before this one), so the view hands it Enter and Escape before its own shortcuts: Enter does not repeat the last placing command, and Escape does not clear the selection, which would apply the crop. The context menu gains "Reset crop", which shows the whole original again, and "Properties...", since double-click no longer opens them. The crop dialog stays available. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 1 + sources/qetgraphicsitem/cropgeometry.h | 97 +++++++ sources/qetgraphicsitem/diagramimageitem.cpp | 273 ++++++++++++++++++- sources/qetgraphicsitem/diagramimageitem.h | 24 +- tests/qttest/CMakeLists.txt | 9 + tests/qttest/tst_cropgeometry.cpp | 107 ++++++++ 6 files changed, 500 insertions(+), 11 deletions(-) create mode 100644 sources/qetgraphicsitem/cropgeometry.h create mode 100644 tests/qttest/tst_cropgeometry.cpp 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/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 ed81b4619..59507b77a 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" @@ -99,6 +100,38 @@ 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); + QPen frame(QColor(255, 140, 0)); + frame.setCosmetic(true); + frame.setWidth(2); + painter -> setPen(frame); + painter -> setBrush(Qt::NoBrush); + painter -> drawRect(cropFrameLocal()); + 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); @@ -461,6 +494,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 +517,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 +547,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 +628,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 +687,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 +755,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 QColor(255, 140, 0); } return Qt::blue; } @@ -648,6 +768,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 +777,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; @@ -849,7 +975,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 +1073,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 +1112,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 +1148,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 +1565,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; @@ -1467,6 +1625,8 @@ QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect */ QRectF DiagramImageItem::boundingRect() const { + if (isCropping()) + return imageRect().united(QRectF(-QPointF(m_crop_rect.topLeft()), m_base_pixmap.size())); if (m_label.isEmpty()) return imageRect(); return imageRect().united(labelRect()); @@ -1872,10 +2032,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")); @@ -1886,9 +2054,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()); @@ -2058,6 +2227,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..a5fb12aa6 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); @@ -267,7 +275,17 @@ class DiagramImageItem : public QetGraphicsItem { QVector m_handleRoles; 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 + bool m_drag_cancelled = false; + // 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); // Escape pressed: ignore the rest of this mouse gesture 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 8653c86a4..4dc696228 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -1092,6 +1092,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"