From d86f2d5461da049b52e127f3e9d3ebce4042ede5 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sun, 4 Oct 2026 19:59:26 +0200 Subject: [PATCH 1/6] 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" From 05131d12389c0d5c1307a297b3c2580ff7c5a50b Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sun, 4 Oct 2026 22:29:47 +0200 Subject: [PATCH 2/6] Show crop bars instead of handle points while cropping Black bars on a white outline, an L at each corner and a short bar in the middle of each side, at the same size on screen at any zoom, with the matching resize cursor over each. The handles underneath still take the drags but are no longer drawn. Co-Authored-By: Claude Opus 5.5 --- sources/qetgraphicsitem/diagramimageitem.cpp | 64 ++++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index 59507b77a..8e840e368 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -42,6 +42,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -119,12 +122,44 @@ void DiagramImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * 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)); + const QRectF f = cropFrameLocal(); + QPen frame(Qt::black); frame.setCosmetic(true); - frame.setWidth(2); + frame.setWidth(1); painter -> setPen(frame); painter -> setBrush(Qt::NoBrush); - painter -> drawRect(cropFrameLocal()); + 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) @@ -755,7 +790,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); + case HandleRole::CropEdge: return Qt::transparent; // paint() draws crop bars instead } return Qt::blue; } @@ -810,6 +845,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); } @@ -1626,7 +1668,19 @@ 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())); + { + // 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()); From d996426e8ac6aecba2942021b36934d79b4e1fab Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Fri, 9 Oct 2026 13:18:54 +0200 Subject: [PATCH 3/6] Translate the texts of cropping a picture on the folio The French of the six new texts (the crop mode's name and hints, "Crop on the folio", "Reset crop", "Properties..."), as lupdate's own entries, inserted where lupdate places them. Co-Authored-By: Claude Opus 5.5 --- lang/qet_fr.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/lang/qet_fr.ts b/lang/qet_fr.ts index 666cb10eb..e84e2c49e 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 ... From 82615242089a867717a3622c9acd1f138bcaa4f3 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sat, 10 Oct 2026 10:54:42 +0200 Subject: [PATCH 4/6] Put the Escape comment back on m_drag_cancelled The comment "Escape pressed: ignore the rest of this mouse gesture" explains m_drag_cancelled. The crop commit moved it onto the declaration of dragCropHandle(), added below it. No code changes. Co-Authored-By: Claude Opus 5.5 --- sources/qetgraphicsitem/diagramimageitem.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index a5fb12aa6..6e0f308f8 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -275,7 +275,7 @@ 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; + 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. @@ -285,7 +285,7 @@ class DiagramImageItem : public QetGraphicsItem { 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 + void dragCropHandle(int index, const QPointF &scenePos); ShapeTransform m_original_transform; bool m_original_pivotIsCustom = false; bool m_deferHandleReposition = false; // see setPivot()'s comment From 446479e8d2751ced264bf0a331ba229dd44048aa Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sat, 10 Oct 2026 10:55:13 +0200 Subject: [PATCH 5/6] Fix a crop in progress outliving an undo or the picture's removal While a picture was being cropped on the folio: - Ctrl+Z or Ctrl+Y of an earlier crop of the same picture left the crop handles where they were, 10 px beside the frame for a crop that starts at (10, 10). - undoing the picture's insertion dropped the crop but left the picture as the folio's keyboard item, also after a redo put it back. setImageSource() and leaving the scene now end the crop without applying it. Nothing is pushed on the undo stack from there, since both can run inside an undo command. Delete and cut are unchanged: they deselect the picture first, which applies the crop as its own undo step before theirs. No file-format change. Not covered by an automated test: crop mode needs a folio and its view, which a test can only get from the application object library of #1431. Checked with a throw-away test on a local merge of both branches. Co-Authored-By: Claude Opus 5.5 --- sources/qetgraphicsitem/diagramimageitem.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index 8e840e368..bc09d16a2 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -281,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; @@ -1626,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()) From ec5246f6dbfc8ddd7090dbc0bd5d58bf6632c2ce Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sat, 10 Oct 2026 10:56:10 +0200 Subject: [PATCH 6/6] Use relative paths for the crop texts' locations in qet_fr.ts The six lines the crop translation added pointed into the folder the branch was built in. They are relative to lang/ now, like the others in the file, with the current line numbers. No text or translation changes. Co-Authored-By: Claude Opus 5.5 --- lang/qet_fr.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lang/qet_fr.ts b/lang/qet_fr.ts index e84e2c49e..33c5514f8 100644 --- a/lang/qet_fr.ts +++ b/lang/qet_fr.ts @@ -2213,7 +2213,7 @@ Continuer ? pivoter/incliner - + crop rogner @@ -2223,7 +2223,7 @@ Continuer ? 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 @@ -2268,7 +2268,7 @@ Continuer ? Glisser : déplacer le centre de rotation - + Drag: crop (Enter = apply, Escape = cancel) Glisser : rogner (Entrée = appliquer, Échap = annuler) @@ -2375,7 +2375,7 @@ Continuer ? Couleur transparente... - + Crop on the folio Rogner sur le folio @@ -2385,7 +2385,7 @@ Continuer ? Rogner... - + Reset crop Réinitialiser le rognage @@ -2405,7 +2405,7 @@ Continuer ? Restaurer les proportions - + Properties... Propriétés...