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"