From 6b577ee75727158e12702ebe9c16d2b3ee0b8fc1 Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Sat, 5 Sep 2026 15:48:28 +0200 Subject: [PATCH] Add editable resize/rotate/skew handles for shapes and images Shapes and images can now be resized, rotated, and skewed directly on the canvas, not just moved. Both share one small transform struct (rotation, then skew, then scale, anchored on a movable pivot) and one handle widget, so a corner drag, an edge skew, or grabbing the rotate handle behaves the same way and runs through the same matrix math everywhere, instead of every item type reinventing its own. Shapes also gained a proper pen tool (bezier paths, corner/smooth/ symmetric nodes), arc support, and mirroring. Images gained non-destructive cropping and colour-keyed transparency, both remember their own settings, so reopening the dialog picks up where you left off instead of starting over. Properties dialogs for both were extended to match (position, size, angle, skew), with undo/redo wired through for every handle drag. Old XML files can read easily as the transformation is only added if needed and the old syntax is still used and understood if it is not needed. --- cmake/qet_compilation_vars.cmake | 10 + .../apps/hidef/draw-bezier-curves.svg | 12 + qelectrotech.qrc | 1 + sources/diagramevent/diagrameventaddimage.cpp | 160 +- sources/diagramevent/diagrameventaddimage.h | 7 + sources/diagramevent/diagrameventaddpath.cpp | 427 +++ sources/diagramevent/diagrameventaddpath.h | 81 + sources/diagramevent/diagrameventaddshape.cpp | 276 +- sources/diagramevent/diagrameventaddshape.h | 15 + sources/qetdiagrameditor.cpp | 64 +- sources/qetgraphicsitem/diagramimageitem.cpp | 1457 +++++++++- sources/qetgraphicsitem/diagramimageitem.h | 136 +- sources/qetgraphicsitem/qetshapeitem.cpp | 2529 +++++++++++++++-- sources/qetgraphicsitem/qetshapeitem.h | 201 +- sources/qetgraphicsitem/shapetransform.cpp | 88 + sources/qetgraphicsitem/shapetransform.h | 90 + sources/qeticons.cpp | 2 + sources/qeticons.h | 1 + sources/ui/imagecropdialog.cpp | 287 ++ sources/ui/imagecropdialog.h | 121 + sources/ui/imagepropertieswidget.cpp | 251 +- sources/ui/imagepropertieswidget.h | 23 +- sources/ui/imagepropertieswidget.ui | 211 +- sources/ui/imagetransparentcolordialog.cpp | 335 +++ sources/ui/imagetransparentcolordialog.h | 133 + .../ui/shapegraphicsitempropertieswidget.cpp | 117 +- .../ui/shapegraphicsitempropertieswidget.ui | 77 +- sources/undocommand/promoteshapecommand.cpp | 43 + sources/undocommand/promoteshapecommand.h | 62 + 29 files changed, 6784 insertions(+), 433 deletions(-) create mode 100644 ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg create mode 100644 sources/diagramevent/diagrameventaddpath.cpp create mode 100644 sources/diagramevent/diagrameventaddpath.h create mode 100644 sources/qetgraphicsitem/shapetransform.cpp create mode 100644 sources/qetgraphicsitem/shapetransform.h create mode 100644 sources/ui/imagecropdialog.cpp create mode 100644 sources/ui/imagecropdialog.h create mode 100644 sources/ui/imagetransparentcolordialog.cpp create mode 100644 sources/ui/imagetransparentcolordialog.h create mode 100644 sources/undocommand/promoteshapecommand.cpp create mode 100644 sources/undocommand/promoteshapecommand.h diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 2bc2edcce..7fc68a769 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -318,6 +318,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/diagramevent/diagrameventaddimage.h ${QET_DIR}/sources/diagramevent/diagrameventaddshape.cpp ${QET_DIR}/sources/diagramevent/diagrameventaddshape.h + ${QET_DIR}/sources/diagramevent/diagrameventaddpath.cpp + ${QET_DIR}/sources/diagramevent/diagrameventaddpath.h ${QET_DIR}/sources/diagramevent/diagrameventaddtext.cpp ${QET_DIR}/sources/diagramevent/diagrameventaddtext.h ${QET_DIR}/sources/diagramevent/diagrameventinterface.cpp @@ -518,6 +520,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/qetgraphicsitem/qetgraphicsitem.h ${QET_DIR}/sources/qetgraphicsitem/qetshapeitem.cpp ${QET_DIR}/sources/qetgraphicsitem/qetshapeitem.h + ${QET_DIR}/sources/qetgraphicsitem/shapetransform.cpp + ${QET_DIR}/sources/qetgraphicsitem/shapetransform.h ${QET_DIR}/sources/qetgraphicsitem/qgraphicsitemutility.cpp ${QET_DIR}/sources/qetgraphicsitem/qgraphicsitemutility.h ${QET_DIR}/sources/qetgraphicsitem/reportelement.cpp @@ -718,8 +722,12 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/elementpropertieswidget.h ${QET_DIR}/sources/ui/formulaassistantdialog.cpp ${QET_DIR}/sources/ui/formulaassistantdialog.h + ${QET_DIR}/sources/ui/imagecropdialog.cpp + ${QET_DIR}/sources/ui/imagecropdialog.h ${QET_DIR}/sources/ui/imagepropertieswidget.cpp ${QET_DIR}/sources/ui/imagepropertieswidget.h + ${QET_DIR}/sources/ui/imagetransparentcolordialog.cpp + ${QET_DIR}/sources/ui/imagetransparentcolordialog.h ${QET_DIR}/sources/ui/importelementdialog.cpp ${QET_DIR}/sources/ui/importelementdialog.h ${QET_DIR}/sources/ui/importelementtextpatterndialog.cpp @@ -790,6 +798,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/undocommand/setautonumcontextcommand.h ${QET_DIR}/sources/undocommand/rotateselectioncommand.cpp ${QET_DIR}/sources/undocommand/rotateselectioncommand.h + ${QET_DIR}/sources/undocommand/promoteshapecommand.cpp + ${QET_DIR}/sources/undocommand/promoteshapecommand.h ${QET_DIR}/sources/undocommand/rotatetextscommand.cpp ${QET_DIR}/sources/undocommand/rotatetextscommand.h ${QET_DIR}/sources/undocommand/movegraphicsitemcommand.cpp diff --git a/ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg b/ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg new file mode 100644 index 000000000..677ae6200 --- /dev/null +++ b/ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/qelectrotech.qrc b/qelectrotech.qrc index 531dde1f4..17fb20b03 100644 --- a/qelectrotech.qrc +++ b/qelectrotech.qrc @@ -548,6 +548,7 @@ ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-element.svgz ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-project.svgz ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-titleblock.svgz + ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg ico/16x16/object-group.png ico/mac_icon/elmt.icns ico/mac_icon/qelectrotech.icns diff --git a/sources/diagramevent/diagrameventaddimage.cpp b/sources/diagramevent/diagrameventaddimage.cpp index 8b091a75e..a8e145659 100644 --- a/sources/diagramevent/diagrameventaddimage.cpp +++ b/sources/diagramevent/diagrameventaddimage.cpp @@ -19,10 +19,14 @@ #include "diagrameventaddimage.h" #include "../qetapp.h" +#include "../qetdiagrameditor.h" #include "../diagram.h" #include "../undocommand/addgraphicsobjectcommand.h" #include "../qetgraphicsitem/diagramimageitem.h" +#include +#include + /** @brief DiagramEventAddImage::DiagramEventAddImage Default constructor @@ -34,6 +38,16 @@ DiagramEventAddImage::DiagramEventAddImage(Diagram *diagram) : m_is_added (false) { openDialog(); + if (m_running) + { + // Deferred for the same reason as the shape tools' own + // constructor-time hint: Diagram::setEventInterface() destroys + // whatever tool was previously active *after* this constructor + // returns, and that tool's own destructor clears the status bar + // -- an immediate show here would just get wiped out moments + // later by that cleanup. + QTimer::singleShot(0, this, [this]() { showHint(); }); + } } /** @@ -47,33 +61,61 @@ DiagramEventAddImage::~DiagramEventAddImage() delete m_image; } + if (!m_diagram->views().isEmpty()) + { + if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst())) + editor->statusBar()->clearMessage(); + } + foreach (QGraphicsView *view, m_diagram->views()) view->setContextMenuPolicy((Qt::DefaultContextMenu)); } +/** + @brief DiagramEventAddImage::showHint + Re-asserted on every move (see mouseMoveEvent), not just once at + activation: Qt's own built-in "show an action's statusTip on hover" + has its own internal "restore whatever was there before" logic for + when the hover ends. Since this message is first shown *during* that + same hover session (the user is still over the toolbar icon when the + deferred constructor-time call above fires), Qt's hover-tracking has + no idea this code changed the status bar in the meantime -- the + moment the mouse leaves the icon for the canvas, it silently + restores whatever it remembers being there before its own tip + started, overwriting this one. Re-showing it on every move within + the canvas simply outlasts that one-time restore -- the exact same + issue already found and fixed for the shape tools. +*/ +void DiagramEventAddImage::showHint() const +{ + if (m_diagram->views().isEmpty()) + return; + if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst())) + editor->statusBar()->showMessage(tr("Clic : positionner à la taille d'origine. " + "Cliquer-glisser : positionner et redimensionner. " + "Clic droit : pivoter de 90°. Ctrl+molette : ajuster la taille.")); +} + /** @brief DiagramEventAddImage::mousePressEvent - Action when mouse is pressed + Left button: starts a potential drag-to-resize, anchored here -- but + doesn't commit to anything yet. A quick click-release (see + mouseMoveEvent's threshold check) still places the image at its + original size, matching the previous behavior exactly; only an + actual drag switches to resizing. Right button still rotates in 90 + degree steps, unchanged, and only while not already left-dragging. @param event : event of mouse pressed */ void DiagramEventAddImage::mousePressEvent(QGraphicsSceneMouseEvent *event) { - if (m_image && event -> button() == Qt::LeftButton) + if (m_image && event->button() == Qt::LeftButton) { - QPointF pos = event->scenePos(); - pos.rx() -= m_image->boundingRect().width()/2; - pos.ry() -= m_image->boundingRect().height()/2; - m_diagram -> undoStack().push (new AddGraphicsObjectCommand(m_image, m_diagram, pos)); - - for (QGraphicsView *view : m_diagram->views()) { - view->setContextMenuPolicy((Qt::DefaultContextMenu)); - } - - m_running = false; - emit finish(); + m_pressed = true; + m_resize_engaged = false; + m_press_pos = event->scenePos(); event->setAccepted(true); } - else if (m_image && event -> button() == Qt::RightButton) + else if (m_image && !m_pressed && event->button() == Qt::RightButton) { m_image->setRotation(m_image->rotation() + 90); event->setAccepted(true); @@ -87,26 +129,95 @@ void DiagramEventAddImage::mousePressEvent(QGraphicsSceneMouseEvent *event) */ void DiagramEventAddImage::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { - if (!m_image || event->buttons() != Qt::NoButton) { + if (!m_image) { return; } - + + showHint(); + QPointF pos = event->scenePos(); - + if (!m_is_added) { for (QGraphicsView *view : m_diagram->views()) { view->setContextMenuPolicy((Qt::NoContextMenu)); } - + m_diagram->addItem(m_image); m_is_added = true; } - - m_image->setPos(pos - m_image->boundingRect().center()); + + if (m_pressed) + { + // Anchored on m_press_pos, not the item's own current position: + // dragging in any direction has to visibly grow the image from + // where the click started, not from wherever the "no button + // held" preview phase happened to leave it centered. + const QPointF delta = pos - m_press_pos; + if (!m_resize_engaged && QLineF(m_press_pos, pos).length() >= 4.0) + m_resize_engaged = true; // latched: crossing back within the threshold afterward must not un-engage it + + if (!m_resize_engaged) + { + // Still just a (so far) plain click -- keep behaving like + // the pre-drag preview: original size, centered here, so + // releasing right now reproduces the old click-to-place + // behavior exactly. + m_image->setPos(m_press_pos - m_image->boundingRect().center()); + } + else + { + const QSizeF naturalSize = m_image->boundingRect().size(); + if (naturalSize.width() > 0 && naturalSize.height() > 0) + { + const qreal scaleX = qAbs(delta.x()) / naturalSize.width(); + const qreal scaleY = qAbs(delta.y()) / naturalSize.height(); + // The larger of the two, not a per-axis stretch: images + // only support a single uniform scale today (see + // boundingRect()/paint(), which never touch aspect + // ratio), so this is a diagonal-drag size, not a + // free-form one -- breaking aspect ratio on purpose is + // its own, separate, larger piece of work. + const qreal newScale = qBound(0.01, qMax(scaleX, scaleY), 50.0); + m_image->setScale(newScale); + } + m_image->setPos(qMin(m_press_pos.x(), pos.x()), qMin(m_press_pos.y(), pos.y())); + } + } + else + { + m_image->setPos(pos - m_image->boundingRect().center()); + } + event->setAccepted(true); } +/** + @brief DiagramEventAddImage::mouseReleaseEvent + Left button release commits whatever mouseMoveEvent last set -- + original size and centered if the press never turned into a real + drag, or the dragged-out size and position otherwise. Either way, + this is the only place placement is actually finalized now; a plain + click no longer finishes inside mousePressEvent itself, since it has + to wait and see whether a drag follows. + @param event : event of mouse release +*/ +void DiagramEventAddImage::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) +{ + if (m_image && m_pressed && event->button() == Qt::LeftButton) + { + m_diagram->undoStack().push(new AddGraphicsObjectCommand(m_image, m_diagram, m_image->pos())); + + for (QGraphicsView *view : m_diagram->views()) { + view->setContextMenuPolicy((Qt::DefaultContextMenu)); + } + + m_running = false; + emit finish(); + event->setAccepted(true); + } +} + /** @brief DiagramEventAddImage::mouseDoubleClickEvent This method is used only to overwrite double click. @@ -124,7 +235,14 @@ void DiagramEventAddImage::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event */ void DiagramEventAddImage::wheelEvent(QGraphicsSceneWheelEvent *event) { - if (!m_is_added || !m_image || event -> modifiers() != Qt::CTRL) { + // !m_pressed added alongside the modifier fix: without it, wheel + // scaling could fight with an active drag-resize, both trying to + // set scale() from different sources in the same gesture. + // event->modifiers() & Qt::ControlModifier, not != Qt::CTRL: the + // same exact-equality bug already found and fixed several times + // this session elsewhere -- Ctrl held together with any other + // modifier would silently fail to register as Ctrl at all. + if (!m_is_added || !m_image || m_pressed || !(event->modifiers() & Qt::ControlModifier)) { return; } diff --git a/sources/diagramevent/diagrameventaddimage.h b/sources/diagramevent/diagrameventaddimage.h index b81bf7aa9..48e45618d 100644 --- a/sources/diagramevent/diagrameventaddimage.h +++ b/sources/diagramevent/diagrameventaddimage.h @@ -20,6 +20,8 @@ #include "diagrameventinterface.h" +#include + class Diagram; class DiagramImageItem; @@ -37,15 +39,20 @@ class DiagramEventAddImage : public DiagramEventInterface void mousePressEvent (QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent (QGraphicsSceneMouseEvent *event) override; + void mouseReleaseEvent (QGraphicsSceneMouseEvent *event) override; void mouseDoubleClickEvent (QGraphicsSceneMouseEvent *event) override; void wheelEvent (QGraphicsSceneWheelEvent *event) override; bool isNull () const; private: void openDialog(); + void showHint() const; DiagramImageItem *m_image; bool m_is_added; + bool m_pressed = false; // left button held: dragging out a size, not just positioning + bool m_resize_engaged = false; // latched once the drag threshold is crossed, matching the pen tool's own curve-drag threshold convention -- so dragging out and back near the start point doesn't "snap back" to original size before release + QPointF m_press_pos; // scene position of the left-button press, the resize anchor }; #endif // DIAGRAMEVENTADDIMAGE_H diff --git a/sources/diagramevent/diagrameventaddpath.cpp b/sources/diagramevent/diagrameventaddpath.cpp new file mode 100644 index 000000000..f7e8b9c76 --- /dev/null +++ b/sources/diagramevent/diagrameventaddpath.cpp @@ -0,0 +1,427 @@ +/* + 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 "diagrameventaddpath.h" + +#include "../diagram.h" +#include "../lastusedstyle.h" +#include "../qetapp.h" +#include "../qetdiagrameditor.h" +#include "../undocommand/addgraphicsobjectcommand.h" + +#include +#include +#include +#include +#include +#include + +/** + @brief DiagramEventAddPath::DiagramEventAddPath + @param diagram : the diagram where this event must operate +*/ +DiagramEventAddPath::DiagramEventAddPath(Diagram *diagram) : + DiagramEventInterface(diagram), + m_shape_item (nullptr), + m_help_horiz (nullptr), + m_help_verti (nullptr) +{ + m_running = true; + init(); + // Deferred for the same reason as DiagramEventAddShape's own + // constructor-time hint: Diagram::setEventInterface() destroys + // whatever tool was previously active *after* this constructor + // returns, and that tool's own destructor clears the status bar -- + // an immediate show here would just get wiped out moments later. + QTimer::singleShot(0, this, [this]() { showHint(); }); +} + +DiagramEventAddPath::~DiagramEventAddPath() +{ + if ((m_running || m_abort) && m_shape_item) + { + m_diagram->removeItem(m_shape_item); + delete m_shape_item; + } + delete m_help_horiz; + delete m_help_verti; + + if (m_diagram && !m_diagram->views().isEmpty()) + { + if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst())) + editor->statusBar()->clearMessage(); + } + + foreach (QGraphicsView *v, m_diagram->views()) + v->setContextMenuPolicy(Qt::DefaultContextMenu); +} + +/** + @brief DiagramEventAddPath::showHint + Re-asserted on every move within the canvas (see mouseMoveEvent), not + just once at activation: Qt's own built-in "show an action's + statusTip on hover" has its own internal "restore whatever was there + before" logic for when the hover ends. Since this message is first + shown *during* that same hover session (the user is still over the + toolbar icon when the deferred constructor-time call above fires), + Qt's hover-tracking has no idea this code changed the status bar in + the meantime -- the moment the mouse leaves the icon for the canvas, + it silently restores whatever it remembers being there before its + own tip started, overwriting this one. Re-showing it on every move + within the canvas simply outlasts that one-time restore. +*/ +void DiagramEventAddPath::showHint() const +{ + if (!m_diagram || m_diagram->views().isEmpty()) + return; + if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst())) + editor->statusBar()->showMessage(tr("Clic: point anguleux. Cliquer-glisser: point courbe. " + "Clic sur le premier point: fermer. Échap/Entrée: terminer. " + "Clic droit: annuler le dernier point.")); +} + +void DiagramEventAddPath::init() +{ + foreach (QGraphicsView *v, m_diagram->views()) + v->setContextMenuPolicy(Qt::NoContextMenu); +} + +QPointF DiagramEventAddPath::snapped(const QPointF &scenePos, Qt::KeyboardModifiers mods) const +{ + return mods == Qt::ControlModifier ? scenePos : Diagram::snapToGrid(scenePos); +} + +int DiagramEventAddPath::confirmedNodeCount() const +{ + // The trailing element is always the live preview while m_shape_item + // exists; with no shape yet there are no nodes of any kind. + return m_shape_item ? qMax(0, m_nodes.size() - 1) : 0; +} + +/** + @brief DiagramEventAddPath::mousePressEvent + Left click: on the very first click, creates the shape with a real + node *and* an immediate preview node at the same spot, so a segment + exists (even if zero-length) from the start rather than requiring a + second click before anything is visible. On later clicks: either + confirms the live preview into a real point and appends a fresh one + for the next segment, or -- if close enough to the first node -- + closes the path. +*/ +void DiagramEventAddPath::mousePressEvent(QGraphicsSceneMouseEvent *event) +{ + if (Q_UNLIKELY(m_diagram->isReadOnly())) + return; + + if (event->button() != Qt::LeftButton) + { + // Accept every button while this tool is running, not just the + // one it actually acts on -- Diagram::mousePressEvent falls + // through to Qt's own default scene handling for anything left + // unaccepted, which is exactly the kind of competing control + // this tool can't afford while it's supposed to have exclusive + // ownership of input. + event->setAccepted(true); + return; + } + + const QPointF pos = snapped(event->scenePos(), event->modifiers()); + + if (!m_shape_item) + { + m_shape_item = new QetShapeItem(pos, pos, QetShapeItem::Path); + if (LastUsedStyle::hasShapePen()) + m_shape_item->setPen(LastUsedStyle::shapePen()); + if (LastUsedStyle::hasShapeBrush()) + m_shape_item->setBrush(LastUsedStyle::shapeBrush()); + m_diagram->addItem(m_shape_item); + // Handles only ever get built for a selected item. + m_shape_item->setSelected(true); + + QetShapeItem::PathNode node; + node.anchor = pos; + m_nodes << node; + m_nodes << node; // live preview, tracks the mouse from here on + m_shape_item->setPathNodes(m_nodes); + m_shape_item->enableNodeEditMode(); + + m_dragging_node = 0; + event->setAccepted(true); + return; + } + + if (confirmedNodeCount() >= 2 && nearFirstNode(pos)) + { + finishPath(true); + event->setAccepted(true); + return; + } + + // Confirm the preview node as a real point, then append a fresh + // preview (a plain Corner, not a copy of the just-confirmed node's + // kind/handles) for the segment after it. + m_dragging_node = m_nodes.size() - 1; + m_nodes[m_dragging_node].anchor = pos; + + QetShapeItem::PathNode preview; + preview.anchor = pos; + m_nodes << preview; + + m_shape_item->setPathNodes(m_nodes); + m_shape_item->enableNodeEditMode(); + event->setAccepted(true); +} + +/** + @brief DiagramEventAddPath::mouseMoveEvent + Two mutually exclusive behaviours, matching whether a button is held: + with the left button down on a just-placed node, dragging shapes that + node's handles (same convention as editing an existing node -- see + QetShapeItem::dragPathControlHandle()). With no button held, the + trailing preview node instead tracks the mouse, giving the live + rubber-band segment. +*/ +void DiagramEventAddPath::mouseMoveEvent(QGraphicsSceneMouseEvent *event) +{ + updateHelpCross(event->scenePos()); + showHint(); + + if (m_shape_item) + { + const QPointF pos = snapped(event->scenePos(), event->modifiers()); + + if (m_dragging_node >= 0 && (event->buttons() & Qt::LeftButton)) + { + QetShapeItem::PathNode &node = m_nodes[m_dragging_node]; + const QPointF delta = pos - node.anchor; + + // A small threshold so an accidental few-pixel wobble on + // what was meant to be a plain click doesn't silently add + // curve handles the user never intended. + if (QLineF(QPointF(), delta).length() > 3.0) + { + node.kind = QetShapeItem::NodeKind::Smooth; + node.outHandle = delta; + node.inHandle = -delta; + } + else + { + node.kind = QetShapeItem::NodeKind::Corner; + node.outHandle.reset(); + node.inHandle.reset(); + } + m_shape_item->setPathNodes(m_nodes); + } + else if (!(event->buttons() & Qt::LeftButton) && !m_nodes.isEmpty()) + { + m_nodes.last().anchor = pos; + m_shape_item->setPathNodes(m_nodes); + } + } + + // Ours unconditionally while running: a stray, unaccepted move event + // falling through to Qt's default handling risks it dragging our + // selected, movable in-progress shape out from under the tool. + event->setAccepted(true); +} + +/** + @brief DiagramEventAddPath::mouseReleaseEvent + Left release just ends the current node's drag phase (the trailing + preview resumes tracking the mouse on the next move). Right release + steps back one *confirmed* point (the preview is left alone), or + cancels outright once only one remains, or exits the tool entirely if + nothing is in progress at all. +*/ +void DiagramEventAddPath::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) + { + m_dragging_node = -1; + } + else if (event->button() == Qt::RightButton) + { + if (m_shape_item) + { + if (confirmedNodeCount() > 1) + { + m_nodes.remove(m_nodes.size() - 2); // the last *confirmed* node; keep the trailing preview + m_shape_item->setPathNodes(m_nodes); + } + else + { + cancelPath(); + } + } + else + { + m_running = false; + emit finish(); + } + } + event->setAccepted(true); +} + +/** + @brief DiagramEventAddPath::mouseDoubleClickEvent + A double-click is a press, release, press, release, doubleclick + sequence -- the second press already confirmed the preview into a + duplicate point (mousePressEvent can't distinguish a double-click + from two single clicks in the same place) and appended a fresh + preview after it. Dropping the last node here removes that fresh + preview; finishPath()'s own trailing-preview removal then removes the + duplicate underneath it, leaving only the genuinely-placed points. +*/ +void DiagramEventAddPath::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) +{ + if (m_shape_item && event->button() == Qt::LeftButton && !m_nodes.isEmpty()) + { + m_nodes.removeLast(); + finishPath(false); + } + event->setAccepted(true); +} + +/** + @brief DiagramEventAddPath::keyPressEvent + Escape or Enter finish the path open once at least two real points + exist; Escape with fewer (or none placed at all) cancels/exits + instead, since there's nothing meaningful to keep. +*/ +void DiagramEventAddPath::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Escape) + { + if (m_shape_item && confirmedNodeCount() >= 2) + finishPath(false); + else if (m_shape_item) + cancelPath(); + else + { + m_running = false; + emit finish(); + } + event->accept(); + } + else if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) + && m_shape_item && confirmedNodeCount() >= 2) + { + finishPath(false); + event->accept(); + } +} + +/** + @brief DiagramEventAddPath::finishPath + Strips the trailing live-preview node, commits the in-progress path + onto the undo stack, and resets so the tool is ready to draw another + one immediately -- matching every other shape tool's own behaviour + after finishing a shape. +*/ +void DiagramEventAddPath::finishPath(bool closed) +{ + if (!m_shape_item) + return; + + if (!m_nodes.isEmpty()) + m_nodes.removeLast(); + + if (m_nodes.size() < 2) + { + cancelPath(); + return; + } + + if (closed) + m_shape_item->setClosed(true); + + m_shape_item->setPathNodes(m_nodes); + m_diagram->undoStack().push(new AddGraphicsObjectCommand(m_shape_item, m_diagram)); + m_shape_item = nullptr; + m_nodes.clear(); + m_dragging_node = -1; +} + +/** + @brief DiagramEventAddPath::cancelPath + Discards the in-progress path entirely -- nothing worth keeping (an + empty or single-point path isn't a usable shape). +*/ +void DiagramEventAddPath::cancelPath() +{ + if (m_shape_item) + { + m_diagram->removeItem(m_shape_item); + delete m_shape_item; + m_shape_item = nullptr; + } + m_nodes.clear(); + m_dragging_node = -1; +} + +/** + @brief DiagramEventAddPath::nearFirstNode + m_shape_item's pos()/transform() stay at their identity defaults for + its entire construction here -- nothing during drawing ever touches + them -- so the first node's anchor, stored in local coordinates, is + directly comparable to a scene position without any mapping. +*/ +bool DiagramEventAddPath::nearFirstNode(const QPointF &scenePos) const +{ + if (m_nodes.isEmpty()) + return false; + return QLineF(m_nodes.first().anchor, scenePos).length() <= CLOSE_THRESHOLD; +} + +/** + @brief DiagramEventAddPath::updateHelpCross + Same crosshair guide as every other shape tool (see + DiagramEventAddShape::updateHelpCross) -- duplicated rather than + shared, since the two classes don't otherwise share a common base + beyond DiagramEventInterface. +*/ +void DiagramEventAddPath::updateHelpCross(const QPointF &p) +{ + if (!m_help_horiz || !m_help_verti) + { + QPen pen; + pen.setWidthF(0.4); + pen.setCosmetic(true); + pen.setColor(Diagram::background_color == Qt::darkGray ? Qt::lightGray : Qt::darkGray); + + QRectF rect = m_diagram->border_and_titleblock.insideBorderRect(); + + if (!m_help_horiz) + { + m_help_horiz = new QGraphicsLineItem(rect.topLeft().x(), 0, rect.topRight().x(), 0); + m_help_horiz->setPen(pen); + m_diagram->addItem(m_help_horiz); + } + + if (!m_help_verti) + { + m_help_verti = new QGraphicsLineItem(0, rect.topLeft().y(), 0, rect.bottomLeft().y()); + m_help_verti->setPen(pen); + m_diagram->addItem(m_help_verti); + } + } + + QPointF point = Diagram::snapToGrid(p); + + m_help_horiz->setY(point.y()); + m_help_verti->setX(point.x()); +} diff --git a/sources/diagramevent/diagrameventaddpath.h b/sources/diagramevent/diagrameventaddpath.h new file mode 100644 index 000000000..e93ac3ac9 --- /dev/null +++ b/sources/diagramevent/diagrameventaddpath.h @@ -0,0 +1,81 @@ +/* + 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 DIAGRAMEVENTADDPATH_H +#define DIAGRAMEVENTADDPATH_H + +#include "../qetgraphicsitem/qetshapeitem.h" +#include "diagrameventinterface.h" + +class QGraphicsLineItem; + +/** + @brief The DiagramEventAddPath class + Pen tool: interactively draw a new Path (Bezier) shape, following the + same vocabulary every vector editor's pen tool uses: + - click places a Corner node; + - press-drag-release places a Smooth node, with the drag defining a + pair of mirrored handles (same convention as + QetShapeItem::dragPathControlHandle's "Smooth" mirroring); + - clicking back on the first node closes the path; + - double-click, Enter, or Escape (once 2+ nodes exist) finishes it + open; + - right-click steps back one node; + - right-click or Escape with nothing placed yet cancels the tool. + While running, m_nodes always carries one extra "preview" node at the + end, tracking the mouse so a live rubber-band segment is always + visible -- confirmedNodeCount() excludes it; every public gesture + handler is responsible for stripping it before treating the list as + "the path so far" (see finishPath(), which does this once for every + finishing gesture). +*/ +class DiagramEventAddPath : public DiagramEventInterface +{ + Q_OBJECT + + public: + DiagramEventAddPath(Diagram *diagram); + ~DiagramEventAddPath() override; + + void mousePressEvent (QGraphicsSceneMouseEvent *event) override; + void mouseMoveEvent (QGraphicsSceneMouseEvent *event) override; + void mouseReleaseEvent (QGraphicsSceneMouseEvent *event) override; + void mouseDoubleClickEvent (QGraphicsSceneMouseEvent *event) override; + void keyPressEvent (QKeyEvent *event) override; + void init() override; + + private: + void updateHelpCross (const QPointF &p); + void showHint () const; + void finishPath (bool closed); + void cancelPath (); + bool nearFirstNode (const QPointF &scenePos) const; + int confirmedNodeCount () const; // m_nodes always carries one trailing "preview" node while running; this excludes it + QPointF snapped (const QPointF &scenePos, Qt::KeyboardModifiers mods) const; + + QetShapeItem *m_shape_item; + QVector m_nodes; + int m_dragging_node = -1; + QGraphicsLineItem *m_help_horiz, *m_help_verti; + + // Scene units within which a click on an existing path is + // treated as "on the first node" and closes the shape, rather + // than adding yet another node right next to it. + static constexpr qreal CLOSE_THRESHOLD = 12.0; +}; + +#endif // DIAGRAMEVENTADDPATH_H diff --git a/sources/diagramevent/diagrameventaddshape.cpp b/sources/diagramevent/diagrameventaddshape.cpp index 0ab945266..d19669e76 100644 --- a/sources/diagramevent/diagrameventaddshape.cpp +++ b/sources/diagramevent/diagrameventaddshape.cpp @@ -19,8 +19,16 @@ #include "../diagram.h" #include "../lastusedstyle.h" +#include "../qetapp.h" +#include "../qetdiagrameditor.h" #include "../undocommand/addgraphicsobjectcommand.h" +#include +#include +#include +#include +#include + /** @brief DiagramEventAddShape::DiagramEventAddShape Default constructor @@ -36,6 +44,18 @@ DiagramEventAddShape::DiagramEventAddShape(Diagram *diagram, QetShapeItem::Shape { m_running = true; init(); + // Deferred to the next event-loop iteration, not shown immediately: + // Diagram::setEventInterface() destroys whatever tool was + // previously active *after* this constructor returns, and that + // previous tool's own destructor clears the status bar (see + // ~DiagramEventAddShape() below) -- an immediate show here would + // just get wiped out moments later by that cleanup. Letting the old + // tool's teardown finish first, then showing this one's message, is + // the same fix already used for the tooltip-flicker issue in + // QetShapeItem::refreshInteractionHints(), applied to the same + // class of "something later in the same call chain undoes what I + // just did" ordering problem. + QTimer::singleShot(0, this, [this]() { updateCreationHint(); }); } /** @@ -50,11 +70,123 @@ DiagramEventAddShape::~DiagramEventAddShape() } delete m_help_horiz; delete m_help_verti; + delete m_center_marker; + + if (m_diagram && !m_diagram->views().isEmpty()) + { + if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst())) + editor->statusBar()->clearMessage(); + } foreach (QGraphicsView *v, m_diagram->views()) v->setContextMenuPolicy(Qt::DefaultContextMenu); } +/** + @brief DiagramEventAddShape::applyPosition + Applies a drag/click position to the in-progress shape, honouring two + modifiers that mirror how the very same shape can already be edited + afterward, once placed: + - Ctrl, for Rectangle/Ellipse only: the first click becomes the + shape's *center* rather than a corner, growing symmetrically as + the cursor moves away from it -- the same meaning Ctrl already + has on a Resize handle (anchor at center). Deliberately not + offered for Line: unlike the Rectangle/Ellipse case, there's no + established convention for "a line grows symmetrically from its + middle" to justify it by, so Ctrl for Line means only free + positioning (see the plain grid-snap check above), nothing more. + - Shift, for Rectangle/Ellipse only: forces the bounding box square + (so Ellipse becomes a true circle), using whichever of the two + dragged dimensions is currently larger and mirroring that onto + the other, preserving the direction the user is actually + dragging in. + Both can combine (Ctrl+Shift: a centered square/circle). Whether or + not Ctrl is currently held, the non-anchored branch always rebuilds + from m_anchor_point rather than nudging the existing rect/line -- + otherwise, if Ctrl had been held earlier in the same drag (moving the + shape's own first point to a mirrored position), releasing it would + leave that point stuck there instead of actually restoring it. +*/ +void DiagramEventAddShape::applyPosition(const QPointF &pos, Qt::KeyboardModifiers mods) +{ + if (!m_shape_item) + return; + + if (m_shape_type == QetShapeItem::Polygon) + { + // setP2() has its own dedicated Polygon handling: it moves the + // *last* vertex in place rather than setting a second point, + // which is exactly the live "next segment follows the mouse" + // preview -- the same idea DiagramEventAddPath's trailing + // preview node gives the pen tool. No Ctrl/Shift modifiers apply + // here (those are Rectangle/Ellipse/Line-specific below), so + // this is a direct, unconditional call. + m_shape_item->setP2(pos); + return; + } + + // m_center_anchored is decided once, in mousePressEvent, not + // re-checked here on every call -- re-checking it live meant + // releasing Ctrl mid-drag (something you'd naturally do the moment + // your hand gets tired holding it, long before you're done resizing) + // silently snapped the shape back to corner-anchored, discarding + // what felt like an already-made decision. Deciding it once at the + // first click matches "I held Ctrl when I clicked, so this shape is + // centered" -- a single, predictable rule instead of a live toggle. + QPointF target = pos; + + if ((mods & Qt::ShiftModifier) + && (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse)) + { + const QPointF ref = m_anchor_point; + const qreal dx = target.x() - ref.x(); + const qreal dy = target.y() - ref.y(); + const qreal size = qMax(qAbs(dx), qAbs(dy)); + target.setX(ref.x() + (dx < 0 ? -size : size)); + target.setY(ref.y() + (dy < 0 ? -size : size)); + } + + if (m_center_anchored) + { + const QPointF mirrored = 2 * m_anchor_point - target; + m_shape_item->setRect(QRectF(mirrored, target)); + } + else + { + if (m_shape_type == QetShapeItem::Line) + m_shape_item->setLine(QLineF(m_anchor_point, target)); + else + m_shape_item->setRect(QRectF(m_anchor_point, target)); + } +} + +/** + @brief DiagramEventAddShape::showCenterMarker + Small, filled marker at the anchor point, shown only while Ctrl- + anchoring is actually in effect right now (see applyPosition()) -- + doubling as live confirmation that it is, rather than leaving the + user to infer it purely from how the shape happens to be growing. +*/ +void DiagramEventAddShape::showCenterMarker(const QPointF &scenePos) +{ + if (!m_center_marker) + { + m_center_marker = new QGraphicsEllipseItem(-4, -4, 8, 8); + QPen pen(Qt::red); + pen.setCosmetic(true); + m_center_marker->setPen(pen); + m_center_marker->setBrush(Qt::red); + m_diagram->addItem(m_center_marker); + } + m_center_marker->setPos(scenePos); +} + +void DiagramEventAddShape::hideCenterMarker() +{ + delete m_center_marker; + m_center_marker = nullptr; +} + /** @brief DiagramEventAddShape::mousePressEvent Action when mouse is pressed @@ -67,7 +199,14 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event) } QPointF pos = event->scenePos(); - if (event->modifiers() != Qt::ControlModifier) { + // A bitwise flag check, not exact equality: modifiers() == Ctrl + // alone fails the moment any other key (Shift for the square/circle + // lock, or an incidental platform flag) is also held, silently + // falling through to snapToGrid() even though Ctrl is held -- + // exactly what made Ctrl+Shift together feel "frozen" (both grid- + // snapped *and* square-locked, quantizing to whichever is coarser) + // and made "Ctrl = free positioning" not actually hold up. + if (!(event->modifiers() & Qt::ControlModifier)) { pos = Diagram::snapToGrid(pos); } @@ -78,6 +217,15 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event) if (!m_shape_item) { m_shape_item = new QetShapeItem(pos, pos, m_shape_type); + m_anchor_point = pos; + // Decided once, here, rather than re-checked on every mouse + // move for the rest of the drag -- see applyPosition()'s doc + // comment for why continuous re-checking made releasing Ctrl + // mid-drag feel like a bug rather than a deliberate choice. + m_center_anchored = (event->modifiers() & Qt::ControlModifier) + && (m_shape_type == QetShapeItem::Rectangle || m_shape_type == QetShapeItem::Ellipse); + if (m_center_anchored) + showCenterMarker(m_anchor_point); //Start from whatever pen/brush was last applied this //session, rather than always the hardcoded default. if (LastUsedStyle::hasShapePen()) { @@ -87,6 +235,7 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event) m_shape_item->setBrush(LastUsedStyle::shapeBrush()); } m_diagram->addItem (m_shape_item); + updateCreationHint(); event->setAccepted(true); return; } @@ -94,12 +243,14 @@ void DiagramEventAddShape::mousePressEvent(QGraphicsSceneMouseEvent *event) //If current item isn't a polyline, add it with an undo command if (m_shape_type != QetShapeItem::Polygon) { - m_shape_item->setP2 (pos); + applyPosition(pos, event->modifiers()); if (m_shape_item->shapeType() == QetShapeItem::Rectangle || m_shape_item->shapeType() == QetShapeItem::Ellipse) { m_shape_item->setRect(m_shape_item->rect().normalized()); } m_diagram->undoStack().push (new AddGraphicsObjectCommand(m_shape_item, m_diagram)); m_shape_item = nullptr; //< set to nullptr for create new shape at next left clic + hideCenterMarker(); + updateCreationHint(); } //Else add a new point to polyline else @@ -125,18 +276,76 @@ void DiagramEventAddShape::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { updateHelpCross(event->scenePos()); + // Re-asserted on every move, not just once at activation: Qt's own + // built-in "show an action's statusTip on hover" has its own + // internal "restore whatever was there before" logic for when the + // hover ends. Since our message is shown *during* that same hover + // session (the user is still over the toolbar icon when the + // deferred constructor-time call fires), Qt's hover-tracking has no + // idea we changed the status bar in the meantime -- the moment the + // mouse leaves the icon for the canvas, it "restores" to whatever it + // remembers being there before its own tip started, which is stale + // and empty, silently overwriting ours. Re-showing it here, on every + // move within the canvas, simply outlasts that one-time restore. + updateCreationHint(); + if (m_shape_item && event->buttons() == Qt::NoButton) { + m_last_mouse_scene_pos = event->scenePos(); // raw, before snapping -- see reapplyLastPosition() + QPointF pos = event->scenePos(); - if (event->modifiers() != Qt::ControlModifier) { + if (!(event->modifiers() & Qt::ControlModifier)) { pos = Diagram::snapToGrid(pos); } - m_shape_item->setP2 (pos); + applyPosition(pos, event->modifiers()); event->setAccepted(true); } } +/** + @brief DiagramEventAddShape::keyPressEvent / keyReleaseEvent + Pressing or releasing Shift (the square/circle lock) does nothing + visible on its own -- applyPosition() only ever runs from + mouseMoveEvent, so without this, a keyboard-only change just sits + there until the next, often incidental, pixel of mouse movement + brings the shape in line with it. That's exactly what looked like a + freeze: holding Shift while the mouse is genuinely still produces no + visible change (correctly -- nothing has moved), and it only + "unsticks" once the mouse moves again, which released keys tend to + coincide with purely by hand tremor, not because releasing itself + did anything. Re-running the last known mouse position through + applyPosition() here makes the key press or release itself the + trigger, giving immediate feedback instead of waiting on chance. +*/ +void DiagramEventAddShape::keyPressEvent(QKeyEvent *event) +{ + reapplyLastPosition(event); +} + +void DiagramEventAddShape::keyReleaseEvent(QKeyEvent *event) +{ + reapplyLastPosition(event); +} + +void DiagramEventAddShape::reapplyLastPosition(QKeyEvent *event) +{ + if (!m_shape_item || (event->key() != Qt::Key_Shift && event->key() != Qt::Key_Control)) + return; + + // A fresh, global query, not event->modifiers(): for a press/release + // of a modifier key itself, whether that key is already reflected in + // the key event's own modifiers() is ambiguous and platform- + // dependent -- the same reason Diagram::snapToGrid() queries this + // directly rather than trusting a passed-in modifiers() value. + const Qt::KeyboardModifiers mods = QGuiApplication::keyboardModifiers(); + QPointF pos = m_last_mouse_scene_pos; + if (!(mods & Qt::ControlModifier)) + pos = Diagram::snapToGrid(pos); + + applyPosition(pos, mods); +} + /** @brief DiagramEventAddShape::mouseReleaseEvent Action when mouse button is released @@ -155,7 +364,7 @@ void DiagramEventAddShape::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) m_shape_item->removePoints(); QPointF pos = event->scenePos(); - if (event->modifiers() != Qt::ControlModifier) + if (!(event->modifiers() & Qt::ControlModifier)) pos = Diagram::snapToGrid(pos); m_shape_item->setP2(pos); //Set the new last point under the cursor @@ -167,6 +376,8 @@ void DiagramEventAddShape::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) m_diagram->removeItem(m_shape_item); delete m_shape_item; m_shape_item = nullptr; + hideCenterMarker(); + updateCreationHint(); event->setAccepted(true); return; } @@ -203,6 +414,8 @@ void DiagramEventAddShape::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event } m_diagram->undoStack().push (new AddGraphicsObjectCommand(m_shape_item, m_diagram)); m_shape_item = nullptr; //< set to nullptr for create new shape at next left clic + hideCenterMarker(); + updateCreationHint(); event->setAccepted(true); } } @@ -213,6 +426,59 @@ void DiagramEventAddShape::init() v->setContextMenuPolicy(Qt::NoContextMenu); } +/** + @brief DiagramEventAddShape::updateCreationHint + Shows whichever of beforeClickHint()/afterClickHint() matches the + current phase -- there was previously either no message at all + (Line/Rectangle/Ellipse) or a single static one that never changed + regardless of progress (Polygon, set externally in + QETDiagramEditor::addItemGroupTriggered()); this replaces both with + one phase-aware message per shape type, managed by the tool itself. +*/ +void DiagramEventAddShape::updateCreationHint() const +{ + if (!m_diagram || m_diagram->views().isEmpty()) + return; + if (auto *editor = QETApp::diagramEditorAncestorOf(m_diagram->views().constFirst())) + editor->statusBar()->showMessage(m_shape_item ? afterClickHint() : beforeClickHint()); +} + +QString DiagramEventAddShape::beforeClickHint() const +{ + switch (m_shape_type) + { + case QetShapeItem::Line: + return tr("Clic gauche : positionner le point de départ (Ctrl = position libre)"); + case QetShapeItem::Rectangle: + case QetShapeItem::Ellipse: + return tr("Clic gauche : positionner le premier coin (Ctrl = point central, position libre)"); + case QetShapeItem::Polygon: + return tr("Clic gauche : positionner le premier point (Ctrl = position libre)"); + default: + return QString(); + } +} + +QString DiagramEventAddShape::afterClickHint() const +{ + switch (m_shape_type) + { + case QetShapeItem::Line: + return tr("Clic gauche : positionner le point final (Ctrl = position libre) ; clic droit : annuler"); + case QetShapeItem::Rectangle: + return tr("Clic gauche : positionner le coin opposé (Maj = carré, " + "Ctrl = depuis le centre + position libre, Ctrl+Maj = carré centré) ; clic droit : annuler"); + case QetShapeItem::Ellipse: + return tr("Clic gauche : positionner le coin opposé (Maj = cercle, " + "Ctrl = depuis le centre + position libre, Ctrl+Maj = cercle centré) ; clic droit : annuler"); + case QetShapeItem::Polygon: + return tr("Clic gauche : point suivant ; double-clic ou Entrée : terminer ; " + "clic droit : annuler le dernier point"); + default: + return QString(); + } +} + /** @brief DiagramEventAddShape::updateHelpCross Create and update the position of the cross to help user for draw new shape diff --git a/sources/diagramevent/diagrameventaddshape.h b/sources/diagramevent/diagrameventaddshape.h index 60a66615a..7999897f6 100644 --- a/sources/diagramevent/diagrameventaddshape.h +++ b/sources/diagramevent/diagrameventaddshape.h @@ -21,6 +21,8 @@ #include "../qetgraphicsitem/qetshapeitem.h" #include "diagrameventinterface.h" +class QGraphicsEllipseItem; + /** @brief The DiagramEventAddShape class This event manage the creation of a shape. @@ -37,15 +39,28 @@ class DiagramEventAddShape : public DiagramEventInterface void mouseMoveEvent (QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent (QGraphicsSceneMouseEvent *event) override; void mouseDoubleClickEvent (QGraphicsSceneMouseEvent *event) override; + void keyPressEvent (QKeyEvent *event) override; + void keyReleaseEvent (QKeyEvent *event) override; void init() override; private: void updateHelpCross (const QPointF &p); + void applyPosition (const QPointF &pos, Qt::KeyboardModifiers mods); + void updateCreationHint () const; + QString beforeClickHint () const; + QString afterClickHint () const; + void showCenterMarker (const QPointF &scenePos); + void hideCenterMarker (); + void reapplyLastPosition (QKeyEvent *event); protected: QetShapeItem::ShapeType m_shape_type; QetShapeItem *m_shape_item; QGraphicsLineItem *m_help_horiz, *m_help_verti; + QPointF m_anchor_point; // the shape's first-click point -- meaningful once m_shape_item exists + QGraphicsEllipseItem *m_center_marker = nullptr; // shown only while Ctrl-anchoring is actually in effect, so it doubles as confirmation that it is + bool m_center_anchored = false; // decided once, at the first click -- see applyPosition()'s doc comment for why + QPointF m_last_mouse_scene_pos; // raw, unsnapped -- lets a modifier-only change re-snap correctly when reapplied }; #endif // DIAGRAMEVENTADDSHAPE_H diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index a17412a72..566b6f5c6 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -27,6 +27,7 @@ #include "diagramevent/diagrameventaddpdf.h" #endif #include "diagramevent/diagrameventaddshape.h" +#include "diagramevent/diagrameventaddpath.h" #include "diagramevent/diagrameventaddtext.h" #include "diagramview.h" #include "elementspanelwidget.h" @@ -60,6 +61,7 @@ #include #include #include +#include #ifdef BUILD_WITHOUT_KF # include "ui/nokde/kautosavefile.h" #else @@ -718,6 +720,19 @@ void QETDiagramEditor::setUpActions() connect(&m_zoom_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::zoomGroupTriggered); //Adding action (add text, image, shape...) + // Exclusive (the default) prevents the active action from ever being + // unchecked by clicking it again -- Qt only lets you switch to a + // different one. That's exactly why clicking an already-active + // tool's own icon never actually deactivated it: confirmed by + // logging every addItemGroupTriggered() call and finding + // isChecked()==true on *every* click, including the one meant to + // turn the tool off -- the click-handling code's own "was this an + // uncheck?" check was correct, its precondition just could never + // occur. ExclusiveOptional allows exactly one more transition: + // clicking the currently-checked action unchecks it, leaving none + // checked, which is required for "click the active tool to turn it + // off" to mean anything at the QAction level at all. + m_add_item_actions_group.setExclusionPolicy(QActionGroup::ExclusionPolicy::ExclusiveOptional); QAction *add_text = m_add_item_actions_group.addAction(QET::Icons::PartTextField, tr("Ajouter un champ de texte")); QAction *add_image = m_add_item_actions_group.addAction(QET::Icons::adding_image, tr("Ajouter une image")); #ifdef QET_HAS_QTPDF @@ -727,6 +742,7 @@ void QETDiagramEditor::setUpActions() QAction *add_rectangle = m_add_item_actions_group.addAction(QET::Icons::PartRectangle, tr("Ajouter un rectangle")); QAction *add_ellipse = m_add_item_actions_group.addAction(QET::Icons::PartEllipse, tr("Ajouter une ellipse")); QAction *add_polyline = m_add_item_actions_group.addAction(QET::Icons::PartPolygon, tr("Ajouter une polyligne")); + QAction *add_path = m_add_item_actions_group.addAction(QET::Icons::PartBezier, tr("Ajouter une courbe")); QAction *add_terminal_strip = m_add_item_actions_group.addAction(QET::Icons::TerminalStrip, tr("Ajouter un plan de bornes")); add_text ->setStatusTip(tr("Ajoute un champ de texte sur le folio actuel")); @@ -738,6 +754,7 @@ void QETDiagramEditor::setUpActions() add_rectangle->setStatusTip(tr("Ajoute un rectangle sur le folio actuel")); add_ellipse ->setStatusTip(tr("Ajoute une ellipse sur le folio actuel")); add_polyline ->setStatusTip(tr("Ajoute une polyligne sur le folio actuel")); + add_path ->setStatusTip(tr("Ajoute une courbe de Bézier sur le folio actuel")); add_terminal_strip->setStatusTip(tr("Ajoute un plan de bornier sur le folio actuel")); add_text ->setData(QStringLiteral("text")); @@ -749,6 +766,7 @@ void QETDiagramEditor::setUpActions() add_rectangle->setData(QStringLiteral("rectangle")); add_ellipse ->setData(QStringLiteral("ellipse")); add_polyline ->setData(QStringLiteral("polyline")); + add_path ->setData(QStringLiteral("path")); add_terminal_strip->setData(QStringLiteral("terminal_strip")); add_text->setCheckable(true); @@ -756,6 +774,7 @@ void QETDiagramEditor::setUpActions() add_rectangle->setCheckable(true); add_ellipse->setCheckable(true); add_polyline->setCheckable(true); + add_path->setCheckable(true); connect(&m_add_item_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::addItemGroupTriggered); @@ -1555,6 +1574,27 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action) if (Q_UNLIKELY (!currentDiagramView() || !currentDiagramView()->diagram() || value.isEmpty())) return; Diagram *d = currentDiagramView()->diagram(); + + // This action group allows deselecting the currently-active tool by + // clicking its own icon again, not just switching between tools -- + // that click still fires this slot (QActionGroup::triggered fires on + // every click in the group, checked-state-changing or not), so + // without this check it would unconditionally construct *another* + // instance of the same tool and reactivate it, leaving the tool + // fully active in the canvas while its own toolbar button shows + // unchecked -- exactly backwards from what the click was for. + // Scoped to checkable actions specifically: image/pdf/terminal_strip + // are one-shot actions (pick a file, open a dialog) with no + // persistent "active tool" state at all, and aren't even checkable + // -- isChecked() on them is always false, so without this guard + // they'd hit the branch above on every single click and never + // reach their own handling below. + if (action->isCheckable() && !action->isChecked()) + { + d->clearEventInterface(); + return; + } + DiagramEventInterface *diagram_event = nullptr; if (value == "line") @@ -1564,12 +1604,10 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action) else if (value == "ellipse") diagram_event = new DiagramEventAddShape (d, QetShapeItem::Ellipse); else if (value == "polyline") - { diagram_event = new DiagramEventAddShape (d, QetShapeItem::Polygon); - statusBar()-> showMessage(tr("Double-click pour terminer la forme, Click droit pour annuler le dernier point")); - connect(diagram_event, &DiagramEventInterface::destroyed, [this]() { - statusBar()->clearMessage(); - }); + else if (value == "path") + { + diagram_event = new DiagramEventAddPath (d); } else if (value == "image") { @@ -1612,6 +1650,22 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action) { d->setEventInterface(diagram_event); connect(diagram_event, &DiagramEventInterface::destroyed, [action]() {action->setChecked(false);}); + // Defensive: on this style/theme, the toolbar button bound to an + // exclusive-group action doesn't reliably repaint its checked + // appearance after the group briefly had *no* action checked at + // all (the gap between unchecking one tool and checking one + // again, even the same one) -- confirmed by tracing a real + // recording frame by frame: the status bar correctly showed this + // tool's "before first click" hint (which can only appear from + // inside a freshly-constructed tool's own constructor, so the + // action's checked state and the tool's activation were both + // genuinely correct) while the button itself stayed visually + // unchecked for a sustained period. Forcing an explicit repaint + // here makes the button's appearance match its actual state + // regardless of whether Qt's own change notification fired + // correctly. + if (QWidget *button = m_add_item_tool_bar->widgetForAction(action)) + button->update(); } } diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index 8a7649399..f6fe4bf72 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -18,8 +18,23 @@ #include "diagramimageitem.h" #include "../PropertiesEditor/propertieseditordialog.h" +#include "../QPropertyUndoCommand/qpropertyundocommand.h" #include "../diagram.h" +#include "../diagramview.h" +#include "../qet.h" +#include "../qetapp.h" +#include "../qetdiagrameditor.h" #include "../ui/imagepropertieswidget.h" +#include "../ui/imagecropdialog.h" +#include "../ui/imagetransparentcolordialog.h" +#include "../utils/qetutils.h" +#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h" + +#include +#include +#include +#include +#include /** @brief DiagramImageItem::DiagramImageItem @@ -30,6 +45,7 @@ DiagramImageItem::DiagramImageItem(QetGraphicsItem *parent_item): QetGraphicsItem(parent_item) { setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges); + setAcceptHoverEvents(true); } /** @@ -40,10 +56,18 @@ DiagramImageItem::DiagramImageItem(QetGraphicsItem *parent_item): */ DiagramImageItem::DiagramImageItem(const QPixmap &pixmap, QetGraphicsItem *parent_item): QetGraphicsItem(parent_item), - pixmap_(pixmap) + pixmap_(pixmap), + m_base_pixmap(pixmap), + m_crop_rect(pixmap.rect()) { - setTransformOriginPoint(boundingRect().center()); + // m_transform.toMatrix(), not QGraphicsItem::setRotation()/setScale(): + // those are a single uniform scale() float, which is exactly why an + // image could never break its own aspect ratio before this class + // gained proper independent scaleX/scaleY. + m_transform.pivot = boundingRect().center(); + setTransform(m_transform.toMatrix()); setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges); + setAcceptHoverEvents(true); } /** @@ -52,6 +76,8 @@ DiagramImageItem::DiagramImageItem(const QPixmap &pixmap, QetGraphicsItem *paren */ DiagramImageItem::~DiagramImageItem() { + if (!m_handler_vector.isEmpty()) + qDeleteAll(m_handler_vector); } /** @@ -98,8 +124,963 @@ void DiagramImageItem::editProperty() @param pixmap the new pixmap */ void DiagramImageItem::setPixmap(const QPixmap &pixmap) { + // Only ever mattered once setPixmap() could be called on an + // already-placed item with a differently-sized replacement (see + // replaceImage()) -- previously this only ran from the constructor, + // before the item existed in any scene, where there was nothing yet + // to invalidate. Without this, a same-session replace to a + // different-sized image would leave the scene's bounding-rect cache + // stale, the exact class of bug already fixed multiple times this + // session for shapes. + // + // Deliberately does NOT touch m_transform.pivot/pos() here, even + // though a differently-sized pixmap generally means the old pivot + // (a bounding-rect-relative point) no longer means the same thing: + // crop() and replaceImage(), the only two callers that ever change + // the pixmap's size, each have their own specific idea of what + // should happen to position and pivot when they do (see their own + // comments) -- a single generic rule here would fight with + // whichever of them actually needs something more specific. + prepareGeometryChange(); pixmap_ = pixmap; - setTransformOriginPoint(boundingRect().center()); + emit pixmapChanged(); +} + +/** + @brief DiagramImageItem::setScaleFactorX / setScaleFactorY / setRotationAngle + Matching QetShapeItem's own setters for the identical fields -- + same pattern, same reasoning: change the one field, rebuild the + matrix, notify. No pivot or position compensation needed here, + unlike setPivot() below, since neither scale nor rotation moves the + pivot itself. +*/ +void DiagramImageItem::setScaleFactorX(qreal factor) +{ + if (qFuzzyCompare(m_transform.scaleX, factor)) return; + prepareGeometryChange(); + m_transform.scaleX = factor; + setTransform(m_transform.toMatrix()); + emit transformChanged(); +} + +void DiagramImageItem::setScaleFactorY(qreal factor) +{ + if (qFuzzyCompare(m_transform.scaleY, factor)) return; + prepareGeometryChange(); + m_transform.scaleY = factor; + setTransform(m_transform.toMatrix()); + emit transformChanged(); +} + +void DiagramImageItem::setRotationAngle(qreal angle) +{ + if (qFuzzyCompare(m_transform.rotation, angle)) return; + prepareGeometryChange(); + m_transform.rotation = angle; + setTransform(m_transform.toMatrix()); + emit transformChanged(); +} + +void DiagramImageItem::setSkewX(qreal skew) +{ + if (qFuzzyCompare(m_transform.skewX, skew)) return; + prepareGeometryChange(); + m_transform.skewX = skew; + setTransform(m_transform.toMatrix()); + emit transformChanged(); +} + +void DiagramImageItem::setSkewY(qreal skew) +{ + if (qFuzzyCompare(m_transform.skewY, skew)) return; + prepareGeometryChange(); + m_transform.skewY = skew; + setTransform(m_transform.toMatrix()); + emit transformChanged(); +} + +/** + @brief DiagramImageItem::setPivot + Unlike scale/rotation, moving the pivot alone WOULD visibly shift + the image on screen, since the pivot is baked directly into the + transform matrix -- compensatedPositionForNewPivot() (already + proven correct for QetShapeItem's identical need) solves for + whatever pos() keeps the image exactly where it already was, so + only the *handle* moves, not the picture underneath it. +*/ +void DiagramImageItem::setPivot(const QPointF &newPivot) +{ + if (m_transform.pivot == newPivot) return; + const QPointF newPos = compensatedPositionForNewPivot(pos(), m_transform.pivot, newPivot, m_transform.linearPart()); + prepareGeometryChange(); + m_transform.pivot = newPivot; + // Verified numerically that the underlying math keeps the image + // exactly in place when the pivot moves -- this guard is about a + // DIFFERENT problem: setPos() and setTransform() are two separate + // QGraphicsItem operations, each independently triggering + // itemChange(), so without this, repositionHandles() (and, + // visibly, a render) could happen using the new pos() but the + // still-old transform (or vice versa) in the moment between the + // two calls -- a real, if brief, wobble, not a calculation error. + // QetShapeItem's own setPivot() already needed the identical fix + // for the identical reason. + m_deferHandleReposition = true; + // QGraphicsObject::setPos(), not the plain setPos() this class + // would otherwise inherit: QetGraphicsItem overrides setPos() to + // snap to the diagram's grid, which is exactly right for a user + // dragging the whole image around, but silently corrupts this + // specific, exactly-computed compensation -- rounding it to the + // nearest grid point defeats the entire point of computing an + // exact value in the first place, and was the actual cause of the + // "picture drifts slightly" symptom (invisible at scale 1 with no + // rotation, since the compensation is a no-op there regardless). + QGraphicsObject::setPos(newPos); + setTransform(m_transform.toMatrix()); + m_deferHandleReposition = false; + repositionHandles(); + emit transformChanged(); +} + +/** + @brief DiagramImageItem::setPivotRaw + Sets m_transform.pivot directly, with NO position compensation at + all -- deliberately, unlike setPivot() above. Exists solely for + crop()'s own undo chain (see the Q_PROPERTY declaration's comment + for why): crop() already computes the fully correct pos() itself, + accounting for the crop, and pushes that as its own independent + undo step, so compensating pos() *again* here would silently + corrupt it back to a wrong value the moment this step replays. + Never call this expecting the image to stay visually in place on + its own -- it won't; the caller is responsible for that. +*/ +void DiagramImageItem::setPivotRaw(const QPointF &newPivot) +{ + if (m_transform.pivot == newPivot) return; + prepareGeometryChange(); + m_transform.pivot = newPivot; + setTransform(m_transform.toMatrix()); + repositionHandles(); + emit transformChanged(); +} + +/** + @brief DiagramImageItem::resetPivotToBoundingRectCenter + Used after a resize drag ends (see handlerMouseReleaseEvent()) -- + NOT after crop() or replaceImage(), which each compute their own, + more specific position handling instead (see setPixmap()'s comment + for why a single generic rule doesn't fit both of those). +*/ +void DiagramImageItem::resetPivotToBoundingRectCenter() +{ + m_pivotIsCustom = false; + setPivot(boundingRect().center()); +} + +namespace { + // Local-space (natural, unscaled pixmap size) position of each of + // the 8 resize handles. Indices: 0=TL 1=TM 2=TR 3=ML 4=MR 5=BL + // 6=BM 7=BR -- arbitrary but fixed, matched by oppositeHandleIndex() + // and adjustsX()/adjustsY() below. + QPointF handleNaturalPosition(int index, qreal w, qreal h) + { + switch (index) + { + case 0: return QPointF(0, 0); + case 1: return QPointF(w / 2, 0); + case 2: return QPointF(w, 0); + case 3: return QPointF(0, h / 2); + case 4: return QPointF(w, h / 2); + case 5: return QPointF(0, h); + case 6: return QPointF(w / 2, h); + case 7: return QPointF(w, h); + } + return QPointF(); + } + + int oppositeHandleIndex(int index) + { + static const int opposite[8] = {7, 6, 5, 4, 3, 2, 1, 0}; + return opposite[index]; + } + + // Corners adjust both axes; edge midpoints adjust only the axis + // perpendicular to their own edge. + bool adjustsX(int index) { return index != 1 && index != 6; } + bool adjustsY(int index) { return index != 3 && index != 4; } +} + +/** + @brief DiagramImageItem::cornerPosition + Matches QetShapeItem::cornerPoint()'s own slot convention exactly + (0=TL 1=TR 2=BR 3=BL), so the rotate math ported from there (see + dragRotateHandle()) lines up without needing its own re-derivation. +*/ +QPointF DiagramImageItem::cornerPosition(int cornerIndex, qreal w, qreal h) +{ + switch (cornerIndex & 3) + { + case 0: return QPointF(0, 0); + case 1: return QPointF(w, 0); + case 2: return QPointF(w, h); + default: return QPointF(0, h); + } +} + +/** + @brief DiagramImageItem::edgeMidpointPosition + Matches QetShapeItem::edgeMidpoint()'s own slot convention exactly + (0=N 1=E 2=S 3=W), for the same reason as cornerPosition() above. +*/ +QPointF DiagramImageItem::edgeMidpointPosition(int edgeIndex, qreal w, qreal h) +{ + switch (edgeIndex & 3) + { + case 0: return QPointF(w / 2, 0); + case 1: return QPointF(w, h / 2); + case 2: return QPointF(w / 2, h); + default: return QPointF(0, h / 2); + } +} + +/** + @brief DiagramImageItem::scaleOnlyOffset / scaleAndShearOffset + Verbatim ports of QetShapeItem's own identically-named helpers (see + their definitions there for the derivation) -- the offset of a local + point from the pivot, with scale (and, for the second, shear too) + applied but rotation deliberately left out. Used by + dragRotateHandle()/dragSkewHandle() to solve for rotation/skew + directly rather than through mapFromScene(), which would divide out + the very value being solved for. +*/ +QPointF DiagramImageItem::scaleOnlyOffset(const QPointF &localPoint) const +{ + const QPointF offset = localPoint - m_transform.pivot; + return QPointF(offset.x() * m_transform.scaleX, offset.y() * m_transform.scaleY); +} + +QPointF DiagramImageItem::scaleAndShearOffset(const QPointF &localPoint) const +{ + const QPointF scaled = scaleOnlyOffset(localPoint); + const qreal kx = qTan(qDegreesToRadians(m_transform.skewX)); + const qreal ky = qTan(qDegreesToRadians(m_transform.skewY)); + return QPointF(scaled.x() + kx * scaled.y(), scaled.y() + ky * scaled.x()); +} + +/** + @brief DiagramImageItem::handlePosition + Local-space position of handle at vector index `index`, for + whichever role it currently holds -- the single source both + rebuildHandles() and repositionHandles() read from, so the two can + never disagree about where a handle belongs. +*/ +QPointF DiagramImageItem::handlePosition(int index) const +{ + const qreal w = pixmap_.width(), h = pixmap_.height(); + const HandleRole role = m_handleRoles.at(index); + if (role == HandleRole::Resize) + return handleNaturalPosition(index, w, h); + if (role == HandleRole::Rotate) + return cornerPosition(index, w, h); // Rotate handles are always the first 4 in RotateSkew mode, so index IS the corner slot + if (role == HandleRole::SkewEdge) + return edgeMidpointPosition(index - 4, w, h); // ...and SkewEdge the next 4, offset by those same 4 + return m_transform.pivot; // Pivot +} + +/** + @brief DiagramImageItem::mousePressEvent + Clicking an already-selected image cycles its handle mode, exactly + matching QetShapeItem's own established convention for the same + gesture -- deliberately kept consistent rather than inventing a + different interaction just because this is a different class. +*/ +void DiagramImageItem::mousePressEvent(QGraphicsSceneMouseEvent *event) +{ + const bool wasAlreadySelected = isSelected(); + event->ignore(); + QetGraphicsItem::mousePressEvent(event); + + if (event->button() == Qt::LeftButton) + { + if (wasAlreadySelected && isSelected()) + toggleHandleMode(); + event->accept(); + } +} + +/** + @brief DiagramImageItem::toggleHandleMode +*/ +void DiagramImageItem::toggleHandleMode() +{ + prepareGeometryChange(); + m_handleMode = (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size; + rebuildHandles(); + refreshInteractionHints(); +} + +/** + @brief DiagramImageItem::nextHandleMode + @return whichever mode a click would switch to from here -- the + other one, since there are only two. +*/ +DiagramImageItem::HandleMode DiagramImageItem::nextHandleMode() const +{ + return (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size; +} + +/** + @brief DiagramImageItem::handleModeLabel +*/ +QString DiagramImageItem::handleModeLabel(HandleMode mode) +{ + return (mode == HandleMode::Size) ? tr("redimensionner") : tr("pivoter/incliner"); +} + +/** + @brief DiagramImageItem::updateModeHint + Keeps the tooltip in sync with what the next click would do -- + called on selection change (so it appears/disappears with the + handles themselves) and after every mode switch. Ported from + QetShapeItem's identical method: deliberately short, since this is + a tooltip, not documentation -- the fuller gesture/modifier + reference lives in the status bar instead (see + currentModeStatusHint(), hoverEnterEvent()). +*/ +void DiagramImageItem::updateModeHint() +{ + setToolTip(isSelected() + ? tr("Cliquer : mode %1").arg(handleModeLabel(nextHandleMode())) + : QString()); +} + +/** + @brief DiagramImageItem::refreshInteractionHints + Keeps the tooltip text current, and -- if already being hovered -- + immediately re-shows both the tooltip and the status bar hint + rather than leaving them stuck on whatever was true before. Ported + from QetShapeItem's identical method, for the identical reason: Qt + only re-evaluates a tooltip, and this class only re-shows the + status bar, when the cursor *moves* -- selecting an image (often + clicked while the mouse was already sitting on it) or cycling + handle modes (definitely clicked while sitting on it) both change + what should be shown without the cursor moving at all. +*/ +void DiagramImageItem::refreshInteractionHints() +{ + updateModeHint(); + + if (!isHovered() || !isSelected()) + return; + + showStatusHint(currentModeStatusHint()); +} + +/** + @brief DiagramImageItem::currentModeStatusHint + One-line reference for whatever handles are visible right now, + shown in the status bar while hovering a selected image's body -- + the modifier keys in particular (Ctrl, Shift) have no other visible + indication that they do anything at all. Also carries the same + "next mode" information as the tooltip, since the status bar has + room for the full picture in one place. +*/ +QString DiagramImageItem::currentModeStatusHint() const +{ + QString hint = (m_handleMode == HandleMode::Size) + ? tr("Glisser un coin/bord : redimensionner (Ctrl = depuis le centre, Maj = conserver les proportions)") + : tr("Glisser un coin : pivoter (Maj = par pas de 15°) ; glisser un bord : incliner (Maj = par pas de 15°) ; point rouge : déplacer le centre de rotation"); + hint += tr(" -- %1 : mode %2").arg(tr("Cliquer"), handleModeLabel(nextHandleMode())); + return hint; +} + +/** + @brief DiagramImageItem::hoverEnterEvent +*/ +void DiagramImageItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event) +{ + QetGraphicsItem::hoverEnterEvent(event); + refreshInteractionHints(); +} + +/** + @brief DiagramImageItem::hoverLeaveEvent +*/ +void DiagramImageItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event) +{ + QetGraphicsItem::hoverLeaveEvent(event); + clearStatusHint(); +} + +/** + @brief DiagramImageItem::clearHandles +*/ +void DiagramImageItem::clearHandles() +{ + if (!m_handler_vector.isEmpty()) + { + qDeleteAll(m_handler_vector); + m_handler_vector.clear(); + } + m_handleRoles.clear(); +} + +/** + @brief DiagramImageItem::rebuildHandles + A deliberately small switch compared to QetShapeItem's own version: + images have exactly one "shape" (a plain rectangle matching the + pixmap's natural size), so there's no shape-type branching to do -- + just the two handle modes themselves. +*/ +/** + @brief DiagramImageItem::colorForHandleRole / hintForHandleRole + Single source of truth for both rebuildHandles() (which sets each + handle's native tooltip) and sceneEventFilter()'s hover dispatch + (which shows the identical text in the status bar) -- keeping these + as two independently-maintained copies is exactly how they'd + quietly drift apart over time, as already happened once between + writing this pair. +*/ +QColor DiagramImageItem::colorForHandleRole(HandleRole role) +{ + switch (role) + { + case HandleRole::Resize: return Qt::blue; + case HandleRole::Rotate: return Qt::darkGreen; + case HandleRole::SkewEdge: return QColor(255, 140, 0); + case HandleRole::Pivot: return Qt::red; + } + return Qt::blue; +} + +QString DiagramImageItem::hintForHandleRole(HandleRole role) +{ + switch (role) + { + case HandleRole::Resize: return tr("Glisser : redimensionner (Maj = conserver les proportions, Ctrl = depuis le centre)"); + case HandleRole::Rotate: return tr("Glisser : pivoter (Maj = par pas de 15°)"); + case HandleRole::SkewEdge: return tr("Glisser : incliner (Maj = par pas de 15°)"); + case HandleRole::Pivot: return tr("Glisser : déplacer le centre de rotation"); + } + return QString(); +} + +void DiagramImageItem::rebuildHandles() +{ + clearHandles(); + + if (m_handleMode == HandleMode::Size) + { + for (int i = 0; i < 8; ++i) + m_handleRoles << HandleRole::Resize; + } + else // RotateSkew: 4 corners (rotate), then 4 edges (skew), then 1 pivot -- handlePosition() relies on this exact order + { + for (int i = 0; i < 4; ++i) m_handleRoles << HandleRole::Rotate; + for (int i = 0; i < 4; ++i) m_handleRoles << HandleRole::SkewEdge; + m_handleRoles << HandleRole::Pivot; + } + + if (m_handleRoles.isEmpty() || !scene()) + return; + + QVector positions; + for (int i = 0; i < m_handleRoles.size(); ++i) + positions << handlePosition(i); + + m_handler_vector = QetGraphicsHandlerItem::handlerForPoint(mapToScene(positions), QETUtils::graphicsHandlerSize(this)); + + for (int i = 0; i < m_handler_vector.size(); ++i) + { + QetGraphicsHandlerItem *h = m_handler_vector.at(i); + h->setZValue(zValue() + 1); + h->setColor(colorForHandleRole(m_handleRoles.at(i))); + h->setToolTip(hintForHandleRole(m_handleRoles.at(i))); + h->setAcceptHoverEvents(true); + scene()->addItem(h); + h->installSceneEventFilter(this); + } +} + +/** + @brief DiagramImageItem::repositionHandles + Moves the *existing* handle items to match current geometry, without + touching their identity -- safe to call on every frame of a live + drag, matching QetShapeItem's own repositionHandles()/rebuildHandles() + split for the identical reason. +*/ +void DiagramImageItem::repositionHandles() +{ + if (m_handler_vector.isEmpty()) + return; + if (m_handler_vector.size() != m_handleRoles.size()) + { + rebuildHandles(); + return; + } + + for (int i = 0; i < m_handleRoles.size(); ++i) + m_handler_vector.at(i)->setPos(mapToScene(handlePosition(i))); +} + +/** + @brief DiagramImageItem::sceneEventFilter + Dispatches handle interaction events -- structurally identical to + QetShapeItem::sceneEventFilter(), reused as a pattern rather than + shared as code, since the two classes' handle roles are different + enough (no path/polygon/arc concepts here at all) that sharing the + dispatcher itself would need an awkward, indirect abstraction for + very little actual code saved. +*/ +bool DiagramImageItem::sceneEventFilter(QGraphicsItem *watched, QEvent *event) +{ + if (watched->type() != QetGraphicsHandlerItem::Type) + return false; + + QetGraphicsHandlerItem *qghi = qgraphicsitem_cast(watched); + const int index = m_handler_vector.indexOf(qghi); + if (index == -1) + return false; + + if (event->type() == QEvent::GraphicsSceneMousePress) + { + handlerMousePressEvent(index, static_cast(event)->modifiers()); + return true; + } + if (event->type() == QEvent::GraphicsSceneMouseMove) + { + handlerMouseMoveEvent(index, static_cast(event)); + return true; + } + if (event->type() == QEvent::GraphicsSceneMouseRelease) + { + handlerMouseReleaseEvent(index); + return true; + } + if (event->type() == QEvent::GraphicsSceneHoverEnter) + { + // On top of the tooltip Qt already shows natively from the + // handle's own setToolTip() (see rebuildHandles()) -- returning + // false leaves that native tooltip handling alone, matching + // QetShapeItem's identical dual approach for its own handles. + showStatusHint(hintForHandleRole(m_handleRoles.at(index))); + return false; + } + if (event->type() == QEvent::GraphicsSceneHoverLeave) + { + clearStatusHint(); + return false; + } + return false; +} + +/** + @brief DiagramImageItem::showStatusHint +*/ +void DiagramImageItem::showStatusHint(const QString &text) const +{ + if (text.isEmpty() || !diagram() || diagram()->views().isEmpty()) + return; + if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst())) + editor->statusBar()->showMessage(text); +} + +/** + @brief DiagramImageItem::clearStatusHint +*/ +void DiagramImageItem::clearStatusHint() const +{ + if (!diagram() || diagram()->views().isEmpty()) + return; + if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst())) + editor->statusBar()->clearMessage(); +} + +/** + @brief DiagramImageItem::handlerMousePressEvent + For a Resize handle, temporarily repositions the pivot for the + duration of this drag: to the OPPOSITE corner/edge by default (so + that one stays fixed, the usual convention), or to the CENTER if + Ctrl is held at the moment of the press (so the image grows + symmetrically from its middle instead). Decided once here, not + re-checked on every subsequent move -- mirroring the identical fix + already made for shape creation, and for the identical reason: + continuously re-checking made releasing the modifier mid-drag + revert a decision that felt already made, rather than a deliberate + choice made once at the start. + + Verified numerically before relying on this: with the pivot fixed + at whichever reference point applies, solving for a new scale that + puts the dragged handle at the mouse automatically keeps that + reference point exactly where it was, with zero drift, across all 8 + handles and a rotated, non-uniformly-scaled starting transform. + compensatedPositionForNewPivot() is what keeps this repositioning + itself invisible -- without it, temporarily moving the pivot would + visibly jump the image the instant the drag starts. +*/ +void DiagramImageItem::handlerMousePressEvent(int index, Qt::KeyboardModifiers mods) +{ + m_vector_index = index; + m_original_pos = pos(); + m_original_transform = m_transform; + + if (m_handleRoles.at(index) == HandleRole::Resize) + { + m_resizeCenterAnchored = mods & Qt::ControlModifier; + const QPointF referencePoint = m_resizeCenterAnchored + ? QPointF(pixmap_.width() / 2.0, pixmap_.height() / 2.0) + : handleNaturalPosition(oppositeHandleIndex(index), pixmap_.width(), pixmap_.height()); + const QPointF newPos = compensatedPositionForNewPivot(pos(), m_transform.pivot, referencePoint, m_transform.linearPart()); + m_deferHandleReposition = true; + m_transform.pivot = referencePoint; + // QGraphicsObject::setPos(), not the plain setPos() -- see + // setPivot()'s identical comment for why: QetGraphicsItem's + // override snaps to the grid, which would corrupt this exact + // compensation the same way it did there. + QGraphicsObject::setPos(newPos); + setTransform(m_transform.toMatrix()); + m_deferHandleReposition = false; + } +} + +/** + @brief DiagramImageItem::handlerMouseMoveEvent +*/ +void DiagramImageItem::handlerMouseMoveEvent(int index, QGraphicsSceneMouseEvent *event) +{ + const HandleRole role = m_handleRoles.at(index); + + 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 + else if (role == HandleRole::SkewEdge) + dragSkewHandle(index - 4, event->scenePos(), event->modifiers()); // offset by the 4 preceding Rotate handles + else if (role == HandleRole::Pivot) + dragPivot(mapFromScene(event->scenePos())); +} + +/** + @brief DiagramImageItem::handlerMouseReleaseEvent + Two responsibilities, in order: first, Resize's own pivot cleanup + (the pivot was temporarily relocated to the opposite corner or + center in handlerMousePressEvent() purely to make the drag math + simple, and has to move back now that the drag is over, via + resetPivotToBoundingRectCenter(), which itself handles the position + compensation that reset needs) -- but only when the pivot isn't + user-customized, matching dragPivot()'s own comment for why. + Second -- and previously entirely missing -- building the actual + undo command for whatever changed during this drag: every drag + method above mutates m_transform directly and calls setTransform() + immediately, live, with no undo tracking of its own at all, exactly + like QetShapeItem's identical handlerMouseMoveEvent()/dragResize() + pair. QetShapeItem's own handlerMouseReleaseEvent() is where that + gets reconciled into a single undo step per role, comparing + m_original_transform/m_original_pos (captured at press time) against + the now-already-applied live values -- this is a direct port of + that same pattern for this class's own, smaller role set. Pushing a + command whose redo() sets a property to what it's already live at + is intentional, not wasted: redo() runs once immediately, as a + harmless no-op, so the stack has a correct entry for Ctrl+Z without + the live drag needing any awareness of undo at all. For Pivot + specifically, chaining "pos" before "rawPivot" -- not the + compensating "pivot" -- means undoing simply restores both to their + exact recorded values, without a second, redundant compensation + needing to run and (correctly, but confusingly) arrive at the same + place a longer way around. +*/ +void DiagramImageItem::handlerMouseReleaseEvent(int index) +{ + const HandleRole role = m_handleRoles.at(index); + + if (role == HandleRole::Resize && !m_pivotIsCustom) + resetPivotToBoundingRectCenter(); + + if (diagram()) + { + QUndoCommand *undo = nullptr; + + switch (role) + { + case HandleRole::Resize: + if (!qFuzzyCompare(m_transform.scaleX, m_original_transform.scaleX)) + undo = new QPropertyUndoCommand(this, "scaleFactorX", m_original_transform.scaleX, m_transform.scaleX); + if (!qFuzzyCompare(m_transform.scaleY, m_original_transform.scaleY)) + { + if (undo) + new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY, undo); + else + undo = new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY); + } + break; + + case HandleRole::Rotate: + if (!qFuzzyCompare(m_transform.rotation, m_original_transform.rotation)) + undo = new QPropertyUndoCommand(this, "rotationAngle", m_original_transform.rotation, m_transform.rotation); + break; + + case HandleRole::SkewEdge: + if (!qFuzzyCompare(m_transform.skewX, m_original_transform.skewX)) + undo = new QPropertyUndoCommand(this, "skewX", m_original_transform.skewX, m_transform.skewX); + else if (!qFuzzyCompare(m_transform.skewY, m_original_transform.skewY)) + undo = new QPropertyUndoCommand(this, "skewY", m_original_transform.skewY, m_transform.skewY); + break; + + case HandleRole::Pivot: + if (m_transform.pivot != m_original_transform.pivot) + { + undo = new QUndoCommand(tr("Déplacer le centre de rotation d'une image")); + new QPropertyUndoCommand(this, "pos", m_original_pos, pos(), undo); + new QPropertyUndoCommand(this, "rawPivot", m_original_transform.pivot, m_transform.pivot, undo); + } + break; + } + + if (undo) + { + if (undo->text().isEmpty()) + undo->setText(tr("Modifier une image")); + diagram()->undoStack().push(undo); + } + } + + m_vector_index = -1; +} + +/** + @brief DiagramImageItem::dragResize + Verified numerically (all 8 handles, a rotated and non-uniformly + scaled starting transform) before writing this: undoing only + rotation from the target -- never scale, since that's the very + value being solved for -- is what avoids the feedback-loop mistake + a full mapFromScene()-based solve would fall into (mapFromScene() + divides out the CURRENT scale, not the new one, since it doesn't + know a new one is being computed at all). +*/ +void DiagramImageItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods) +{ + const QPointF pivotScene = pos() + m_transform.pivot; + QTransform undoRotation; + undoRotation.rotate(-m_transform.rotation); + const QPointF postScaleOffset = undoRotation.map(mapToScene(localPos) - pivotScene); + + const QPointF handleNatural = handleNaturalPosition(index, pixmap_.width(), pixmap_.height()); + const qreal handleDx = handleNatural.x() - m_transform.pivot.x(); + const qreal handleDy = handleNatural.y() - m_transform.pivot.y(); + + qreal newScaleX = m_transform.scaleX; + qreal newScaleY = m_transform.scaleY; + if (adjustsX(index) && qAbs(handleDx) > 1e-6) + newScaleX = postScaleOffset.x() / handleDx; + if (adjustsY(index) && qAbs(handleDy) > 1e-6) + newScaleY = postScaleOffset.y() / handleDy; + + if (mods & Qt::ShiftModifier) + { + // Preserve the ORIGINAL aspect ratio (captured at press time in + // m_original_transform), not force scaleX==scaleY -- an image + // that wasn't square to begin with should stay proportional to + // itself, not become literally square the moment Shift is held. + const qreal originalRatio = (qFuzzyIsNull(m_original_transform.scaleY)) + ? 1.0 : m_original_transform.scaleX / m_original_transform.scaleY; + if (adjustsX(index) && !adjustsY(index)) + newScaleY = newScaleX / (qFuzzyIsNull(originalRatio) ? 1.0 : originalRatio); + else if (adjustsY(index) && !adjustsX(index)) + newScaleX = newScaleY * originalRatio; + else + { + // Corner handle: whichever axis moved further from its own + // starting value, in relative terms, drives the other. + const qreal relX = qAbs(newScaleX / m_original_transform.scaleX); + const qreal relY = qAbs(newScaleY / m_original_transform.scaleY); + if (relX > relY) + newScaleY = newScaleX / (qFuzzyIsNull(originalRatio) ? 1.0 : originalRatio); + else + newScaleX = newScaleY * originalRatio; + } + } + + // A pixmap can't meaningfully render at zero or negative scale -- + // clamped well above zero rather than letting it vanish or silently + // flip (negative scale is a mirror, and mirroring is already its + // own deliberate, explicit context-menu action, not something a + // fast drag past the opposite edge should trigger by accident). + const qreal MIN_SCALE = 0.02; + if (newScaleX < MIN_SCALE) newScaleX = MIN_SCALE; + if (newScaleY < MIN_SCALE) newScaleY = MIN_SCALE; + + m_transform.scaleX = newScaleX; + m_transform.scaleY = newScaleY; + setTransform(m_transform.toMatrix()); + repositionHandles(); + emit transformChanged(); +} + +/** + @brief DiagramImageItem::dragRotateHandle + Verified numerically before writing this (all 4 corners, WITH + nonzero skew already present -- scaleAndShearOffset() is what + makes the reference direction correctly account for that): the + handle ends up pointing exactly at the mouse's angle from the + pivot, and the pivot itself never moves. Ported from + QetShapeItem::dragRotateHandle() -- see that copy's own comment for + why this is a plain assignment rather than a recurrence (computing + the angle via mapFromScene() would divide out the current rotation + and turn each frame's result into a feedback loop). +*/ +void DiagramImageItem::dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods) +{ + const QPointF scenePivot = pos() + m_transform.pivot; + const qreal angleMouse = qRadiansToDegrees(qAtan2(scenePos.y() - scenePivot.y(), scenePos.x() - scenePivot.x())); + + const QPointF reference = scaleAndShearOffset(cornerPosition(cornerIndex, pixmap_.width(), pixmap_.height())); + const qreal angleReference = qRadiansToDegrees(qAtan2(reference.y(), reference.x())); + + qreal angle = angleMouse - angleReference; + if (mods & Qt::ShiftModifier) + angle = qRound(angle / 15.0) * 15.0; + + m_transform.rotation = angle; + setTransform(m_transform.toMatrix()); + repositionHandles(); + emit transformChanged(); +} + +/** + @brief DiagramImageItem::dragSkewHandle + Verified numerically before writing this (all 4 edges, WITH nonzero + rotation already present): recovers the exact skew angle that would + put the edge handle at a given scene position, for both skewX + (N/S edges) and skewY (E/W edges). Ported from + QetShapeItem::dragSkewHandle() -- see that copy's own comment for + the closed-form derivation (solved for the one skew component being + dragged, holding everything else fixed, since going through + mapFromScene() would divide out the very value being solved for). +*/ +void DiagramImageItem::dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods) +{ + const QPointF pivot = m_transform.pivot; + const QPointF Q = scaleOnlyOffset(edgeMidpointPosition(edgeIndex, pixmap_.width(), pixmap_.height())); + + const qreal rad = qDegreesToRadians(m_transform.rotation); + const qreal c = qCos(rad), s = qSin(rad); + const QPointF targetRel = scenePos - pos() - pivot; + const QPointF M(targetRel.x() * c + targetRel.y() * s, + -targetRel.x() * s + targetRel.y() * c); + + qreal degrees; + if (edgeIndex == 0 || edgeIndex == 2) // N/S edge -> skewX + { + if (qFuzzyIsNull(Q.y())) return; + degrees = qRadiansToDegrees(qAtan((M.x() - Q.x()) / Q.y())); + if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0; + m_transform.skewX = degrees; + } + else // E/W edge -> skewY + { + if (qFuzzyIsNull(Q.x())) return; + degrees = qRadiansToDegrees(qAtan((M.y() - Q.y()) / Q.x())); + if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0; + m_transform.skewY = degrees; + } + + setTransform(m_transform.toMatrix()); + repositionHandles(); + emit transformChanged(); +} + +/** + @brief DiagramImageItem::dragPivot + Marks the pivot as user-customized, the same way QetShapeItem's own + pivot handle does -- so a later resize (see handlerMouseReleaseEvent()) + doesn't silently snap it back to the bounding-rect center the + instant the user deliberately moved it somewhere else. +*/ +void DiagramImageItem::dragPivot(const QPointF &localPos) +{ + m_pivotIsCustom = true; + setPivot(localPos); + repositionHandles(); +} + +/** + @brief DiagramImageItem::restoreAspectRatio + Context-menu action, the direct answer to "restoration of aspect + ratio" from the original wishlist: makes scaleY match scaleX, + keeping the current width fixed. The natural aspect ratio is + already fully accounted for by the pixmap's own width/height (a + uniform scale, by definition, can never distort it) -- the + distortion is entirely scaleX and scaleY disagreeing with each + other, so undoing it is exactly "make them agree", not a + computation involving pixmap_'s own dimensions at all. An earlier + version of this multiplied by the natural height/width ratio a + second time, which double-counted it and left the image still + visibly distorted, just less obviously so. +*/ +void DiagramImageItem::restoreAspectRatio() +{ + if (!diagram() || diagram()->isReadOnly()) + return; + + if (qFuzzyCompare(m_transform.scaleY, m_transform.scaleX)) + return; + + auto *undo = new QPropertyUndoCommand(this, "scaleFactorY", m_transform.scaleY, m_transform.scaleX); + undo->setText(tr("Restaurer les proportions d'une image")); + diagram()->undoStack().push(undo); +} + +/** + @brief DiagramImageItem::itemChange +*/ +QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant &value) +{ + if (change == ItemSelectedHasChanged) + { + if (value.toBool()) + rebuildHandles(); + else + { + prepareGeometryChange(); + clearHandles(); + m_handleMode = HandleMode::Size; + } + refreshInteractionHints(); + } + else if (change == ItemPositionHasChanged || change == ItemTransformHasChanged) + { + if (!m_deferHandleReposition) + repositionHandles(); + } + else if (change == ItemSceneHasChanged) + { + if (!scene()) + setSelected(false); + } + + return QGraphicsItem::itemChange(change, value); +} + +/** + @brief DiagramImageItem::computeDisplayPixmap + Re-derives what pixmap_ should be from first principles: crop the + true original down to the chosen region, then colour-key whichever + colours have been picked out of it. Used whenever crop() or + setTransparentColor() changes one of those two independently, so + the other's effect is correctly re-applied on top rather than lost + or compounded -- cropping after colours were already picked has to + still show them keyed out; picking colours after a crop has to only + ever consider what's still actually part of the image. + @param base the true, uncropped original + @param cropRect the region of base to keep, in base's own coordinates + @param colors colours to key transparent within the cropped region + @param tolerance how loosely to match those colours, 0-100 +*/ +QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList &colors, int tolerance) +{ + const QPixmap cropped = cropRect == base.rect() ? base : base.copy(cropRect); + if (colors.isEmpty()) + return cropped; + return QPixmap::fromImage(ImageTransparentColorDialog::applyColorKey(cropped.toImage(), colors, tolerance)); } /** @@ -153,8 +1134,91 @@ bool DiagramImageItem::fromXml(const QDomElement &e) pixmap.loadFromData(array); setPixmap(pixmap); - setScale(e.attribute("size").toDouble()); - setRotation(e.attribute("rotation").toDouble()); + // Falls back to treating the loaded result as its own base, with no + // remembered crop or colours -- correct both for a genuinely plain + // image and for a file saved before these features existed. + // Overwritten below if the file actually does carry this + // information. + m_base_pixmap = pixmap; + m_crop_rect = pixmap.rect(); + m_transparent_colors.clear(); + m_transparent_tolerance = 10; + + const QDomElement colorsElement = e.firstChildElement("transparent_colors"); + bool hasColors = !colorsElement.isNull(); + if (hasColors) + { + m_transparent_tolerance = colorsElement.attribute("tolerance", "10").toInt(); + for (const QDomElement &colorElement : QET::findInDomElement(colorsElement, "color")) + { + m_transparent_colors.append(QColor( + colorElement.attribute("r").toInt(), + colorElement.attribute("g").toInt(), + colorElement.attribute("b").toInt())); + } + } + + const QDomElement cropElement = e.firstChildElement("crop"); + bool hasCrop = !cropElement.isNull(); + if (hasCrop) + { + m_crop_rect = QRect( + cropElement.attribute("x").toInt(), + cropElement.attribute("y").toInt(), + cropElement.attribute("w").toInt(), + cropElement.attribute("h").toInt()); + } + + // Present, and only meaningful, whenever either of the above is -- + // the base pixmap on its own, with no crop or colours to apply to + // it, wouldn't mean anything. + if (hasColors || hasCrop) + { + const QDomElement baseElement = e.firstChildElement("image_base"); + if (!baseElement.isNull()) + { + const QByteArray baseArray = QByteArray::fromBase64(baseElement.text().toLatin1()); + QPixmap basePixmap; + if (basePixmap.loadFromData(baseArray)) + m_base_pixmap = basePixmap; + } + // m_crop_rect may still refer to a saved file's base image, not + // pixmap (used as a fallback above only when nothing better is + // available) -- clamp it to whatever base actually ended up + // loaded, so an inconsistent or hand-edited file can't produce + // an out-of-bounds crop rect later. + m_crop_rect = m_crop_rect.intersected(m_base_pixmap.rect()); + if (m_crop_rect.isEmpty()) + m_crop_rect = m_base_pixmap.rect(); + } + + // Baseline: the plain "rotation"/"size" attributes, understood by + // every version of this code, past and future -- size applied + // uniformly to both axes, since a single float can't say otherwise. + // Overwritten below if the file actually carries the richer + // element (independent scaleX/scaleY, and/or a custom + // pivot), the same two-tier fallback QetShapeItem's own fromXml() + // already uses for its identical element. + m_transform.rotation = e.attribute("rotation").toDouble(); + m_transform.scaleX = e.attribute("size").toDouble(); + m_transform.scaleY = m_transform.scaleX; + m_transform.pivot = boundingRect().center(); + m_pivotIsCustom = false; + + const QDomElement transformElement = e.firstChildElement("transform"); + if (!transformElement.isNull()) + { + m_transform.rotation = transformElement.attribute("rotation", "0").toDouble(); + m_transform.skewX = transformElement.attribute("skewX", "0").toDouble(); + m_transform.skewY = transformElement.attribute("skewY", "0").toDouble(); + m_transform.scaleX = transformElement.attribute("scaleX", "1").toDouble(); + m_transform.scaleY = transformElement.attribute("scaleY", "1").toDouble(); + m_transform.pivot = QPointF(transformElement.attribute("pivotX", "0").toDouble(), + transformElement.attribute("pivotY", "0").toDouble()); + m_pivotIsCustom = true; + } + setTransform(m_transform.toMatrix()); + //We directly call setPos from QGraphicsObject, because QetGraphicsItem will snap to grid QGraphicsObject::setPos(e.attribute("x").toDouble(), e.attribute("y").toDouble()); setZValue(e.attribute("z", QString::number(this->zValue())).toDouble()); @@ -174,8 +1238,20 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const result.setAttribute("x", QString::number(pos().x())); result.setAttribute("y", QString::number(pos().y())); result.setAttribute("z", QString::number(this->zValue())); - result.setAttribute("rotation", QString::number(QET::correctAngle(rotation()))); - result.setAttribute("size", QString::number(scale())); + // m_transform.rotation/scaleX, not rotation()/scale(): those are + // QGraphicsItem's own convenience properties, and this class no + // longer ever calls their setters at all -- it uses setTransform() + // directly now, the only way to represent independent scaleX/scaleY + // at all, so rotation()/scale() would always read back their + // defaults (0 and 1) regardless of the image's actual, visible + // state. "size" keeps scaleX specifically (not some average of + // scaleX/scaleY) as its best-effort value for older code that only + // ever understood a single uniform scale -- the element + // below is what any code that understands both axes independently + // should prefer instead, exactly mirroring how QetShapeItem's own + // otherwise-identical element already works. + result.setAttribute("rotation", QString::number(QET::correctAngle(m_transform.rotation))); + result.setAttribute("size", QString::number(m_transform.scaleX)); result.setAttribute("is_movable", bool(is_movable_)); //write the pixmap in the xml element after he was been transformed to base64 @@ -186,5 +1262,372 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const QDomText base64 = document.createTextNode(array.toBase64()); result.appendChild(base64); + // Full-fidelity transform, only written when it actually carries + // something the rotation/size attributes above can't already + // express -- exactly the same convention QetShapeItem's own + // identical element already uses, for the same reason: + // this keeps a plain, never-resized-independently image's saved + // file byte-for-byte unchanged. + const bool hasRichTransform = !qFuzzyCompare(m_transform.scaleX, m_transform.scaleY) + || !qFuzzyIsNull(m_transform.skewX) || !qFuzzyIsNull(m_transform.skewY) || m_pivotIsCustom; + if (hasRichTransform) + { + QDomElement transformElement = document.createElement("transform"); + transformElement.setAttribute("rotation", QString::number(m_transform.rotation)); + transformElement.setAttribute("skewX", QString::number(m_transform.skewX)); + transformElement.setAttribute("skewY", QString::number(m_transform.skewY)); + transformElement.setAttribute("scaleX", QString::number(m_transform.scaleX)); + transformElement.setAttribute("scaleY", QString::number(m_transform.scaleY)); + transformElement.setAttribute("pivotX", QString::number(m_transform.pivot.x())); + transformElement.setAttribute("pivotY", QString::number(m_transform.pivot.y())); + result.appendChild(transformElement); + } + + // Only written when there's actually something to remember -- a + // plain image that's never been cropped or had transparency applied + // shouldn't carry this extra weight at all. pixmap_ above already + // reflects the current result on its own, so older code (or a file + // that never used either feature) reads back exactly what it + // always did; this is purely additional context for the dialogs' + // own memory, letting them be reopened non-destructively. + const bool hasCrop = (m_crop_rect != m_base_pixmap.rect()); + const bool hasColors = !m_transparent_colors.isEmpty(); + + if (hasColors) + { + QDomElement colorsElement = document.createElement("transparent_colors"); + colorsElement.setAttribute("tolerance", m_transparent_tolerance); + for (const QColor &color : m_transparent_colors) + { + QDomElement colorElement = document.createElement("color"); + colorElement.setAttribute("r", color.red()); + colorElement.setAttribute("g", color.green()); + colorElement.setAttribute("b", color.blue()); + colorsElement.appendChild(colorElement); + } + result.appendChild(colorsElement); + } + + if (hasCrop) + { + QDomElement cropElement = document.createElement("crop"); + cropElement.setAttribute("x", m_crop_rect.x()); + cropElement.setAttribute("y", m_crop_rect.y()); + cropElement.setAttribute("w", m_crop_rect.width()); + cropElement.setAttribute("h", m_crop_rect.height()); + result.appendChild(cropElement); + } + + if (hasCrop || hasColors) + { + QByteArray baseArray; + QBuffer baseBuffer(&baseArray); + baseBuffer.open(QIODevice::ReadWrite); + m_base_pixmap.save(&baseBuffer, "PNG"); + QDomElement baseElement = document.createElement("image_base"); + baseElement.appendChild(document.createTextNode(baseArray.toBase64())); + result.appendChild(baseElement); + } + return(result); } + +/** + @brief DiagramImageItem::contextMenuEvent + @param event +*/ +void DiagramImageItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) +{ + if (!diagram()) + { + QetGraphicsItem::contextMenuEvent(event); + return; + } + + if (diagram()->selectedItems().isEmpty()) + this->setSelected(true); + + if (isSelected() && scene()->selectedItems().size() == 1) + { + DiagramView *d_view = nullptr; + for (QGraphicsView *view : diagram()->views()) + { + if (view->isActiveWindow()) + { + d_view = dynamic_cast(view); + if (d_view) + continue; + } + } + + if (d_view) + { + QScopedPointer menu(new QMenu()); + + QAction *replace = menu.data()->addAction(tr("Remplacer l'image...")); + connect(replace, &QAction::triggered, this, &DiagramImageItem::replaceImage); + + QAction *transparentColor = menu.data()->addAction(tr("Couleur transparente...")); + connect(transparentColor, &QAction::triggered, this, &DiagramImageItem::setTransparentColor); + + QAction *cropAction = menu.data()->addAction(tr("Rogner...")); + connect(cropAction, &QAction::triggered, this, &DiagramImageItem::crop); + + QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal")); + QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical")); + connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); }); + connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); }); + + QAction *restoreRatio = menu.data()->addAction(tr("Restaurer les 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); + + menu.data()->addSeparator(); + menu.data()->addActions(d_view->contextMenuActions()); + menu.data()->exec(event->screenPos()); + event->accept(); + return; + } + } + + QetGraphicsItem::contextMenuEvent(event); +} + +/** + @brief DiagramImageItem::replaceImage + Context-menu action: swaps the underlying pixmap for one loaded from + a new file, keeping position, rotation and scale untouched -- only + pixmap_ changes, everything else about how this item sits on the + diagram is left exactly as it was. Reuses the same file dialog + filter and error handling as DiagramEventAddImage::openDialog(), so + picking a replacement looks and behaves like picking a new image did + when this item was first inserted. +*/ +void DiagramImageItem::replaceImage() +{ + if (!diagram() || diagram()->isReadOnly()) + return; + + QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first(); + const QString fileName = QFileDialog::getOpenFileName( + parentWidget, tr("Selectionner une image..."), + QETApp::pictureDir(), tr("Image Files (*.png *.jpg *.jpeg *.bmp *.svg)")); + if (fileName.isEmpty()) + return; + + QImage image(fileName); + if (image.isNull()) + { + QMessageBox::critical(parentWidget, tr("Erreur"), tr("Impossible de charger l'image.")); + return; + } + + const QPixmap oldPixmap = pixmap_; + const QPixmap newPixmap = QPixmap::fromImage(image); + + // A wholesale replacement, not an edit of the existing image -- the + // new picture has nothing to do with whatever colours were picked + // or region was cropped for the old one, so this starts that + // memory fresh rather than carrying over choices that would no + // longer make sense. + m_base_pixmap = newPixmap; + m_crop_rect = newPixmap.rect(); + m_transparent_colors.clear(); + + auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); + undo->setText(tr("Remplacer une image")); + diagram()->undoStack().push(undo); +} + +/** + @brief DiagramImageItem::mirror + Context-menu action: flips the pixmap itself, not a transform -- + unlike QetShapeItem::mirror(), which has rotation/skew to contend + with, there's no equivalent linear-transform decomposition needed + here: the bitmap is flipped once, directly, and stays flipped + regardless of whatever rotation is applied on top afterward. +*/ +void DiagramImageItem::mirror(bool horizontal) +{ + if (!diagram() || diagram()->isReadOnly()) + return; + + const QPixmap oldPixmap = pixmap_; + const QTransform flip = horizontal ? QTransform(-1, 0, 0, 1, 0, 0) : QTransform(1, 0, 0, -1, 0, 0); + const QPixmap newPixmap = pixmap_.transformed(flip); + + // The base is flipped the same way, to stay in sync with pixmap_ -- + // but the picked-colours list itself is left untouched: the actual + // colour values don't change when the image is mirrored, only their + // positions, so whatever was already keyed transparent should stay + // remembered and still apply correctly to the flipped version. + m_base_pixmap = m_base_pixmap.transformed(flip); + + // m_crop_rect, unlike the colour list, DOES need to change: it's + // defined in terms of positions within the base, and those + // positions just moved. Width/height and the other axis are + // untouched -- only the axis being flipped needs its edge mirrored + // (dimensions are unaffected by the flip, so it doesn't matter + // whether this reads m_base_pixmap's size from before or after the + // assignment above). + if (horizontal) + m_crop_rect = QRect(m_base_pixmap.width() - m_crop_rect.left() - m_crop_rect.width(), + m_crop_rect.top(), m_crop_rect.width(), m_crop_rect.height()); + else + m_crop_rect = QRect(m_crop_rect.left(), m_base_pixmap.height() - m_crop_rect.top() - m_crop_rect.height(), + m_crop_rect.width(), m_crop_rect.height()); + + auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); + undo->setText(horizontal ? tr("Miroir horizontal d'une image") : tr("Miroir vertical d'une image")); + diagram()->undoStack().push(undo); +} + +/** + @brief DiagramImageItem::setTransparentColor + Context-menu action: opens ImageTransparentColorDialog against + m_base_pixmap (the pristine source), pre-populated with whatever + colours and tolerance were remembered from a previous session -- + both problems fixed together, since they had the same root cause: + passing pixmap_ (the already colour-keyed result) as if it were the + source, with nowhere to remember which colours produced it. Applies + the result the same way replaceImage() and mirror() do, through the + "pixmap" property, so undo/redo stays consistent across all three; + m_base_pixmap itself is deliberately left untouched here, since this + action only ever changes which colours are keyed out of it, not the + source those colours are keyed out of. +*/ +/** + @brief DiagramImageItem::setTransparentColor + Context-menu action: opens ImageTransparentColorDialog against the + CROPPED base (m_base_pixmap.copy(m_crop_rect)), not the full, + uncropped original -- picking a colour from a region that's already + been permanently cropped away would be picking a colour that isn't + even part of the image anymore. Pre-populated with whatever colours + and tolerance were remembered from a previous session. Applies the + result the same way replaceImage() and mirror() do, through the + "pixmap" property, so undo/redo stays consistent across all three; + m_base_pixmap and m_crop_rect are deliberately left untouched here, + since this action only ever changes which colours are keyed out, + never the source region they're keyed out of. +*/ +void DiagramImageItem::setTransparentColor() +{ + if (!diagram() || diagram()->isReadOnly()) + return; + + QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first(); + const QPixmap croppedBase = m_base_pixmap.copy(m_crop_rect); + ImageTransparentColorDialog dialog(croppedBase, m_transparent_colors, m_transparent_tolerance, parentWidget); + if (dialog.exec() != QDialog::Accepted) + return; + + m_transparent_colors = dialog.pickedColors(); + m_transparent_tolerance = dialog.tolerance(); + + const QPixmap oldPixmap = pixmap_; + const QPixmap newPixmap = dialog.resultPixmap(); + + auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); + undo->setText(tr("Définir une couleur transparente")); + diagram()->undoStack().push(undo); +} + +/** + @brief DiagramImageItem::crop + Context-menu action: opens ImageCropDialog against pixmap_ (the + current, already colour-keyed display, so cropping is WYSIWYG + against whatever is actually visible), then applies the chosen + rectangle to both pixmap_ and m_base_pixmap together -- kept in + sync the same way mirror() keeps them in sync, since cropping is a + permanent, geometric change to the image's own content, unlike + setTransparentColor()'s non-destructive colour keying. + + pos() also needs adjusting, not just pixmap_: setPixmap() (called + via the "pixmap" undo command below) recomputes + transformOriginPoint() from the new, smaller boundingRect(), but + pos() itself is untouched by that -- without fixing it up here too, + the surviving content would visually jump to wherever local (0,0) + happens to land after shrinking, rather than staying exactly where + it already was. Chained into one undo step together with the pixmap + change, since undoing a crop has to restore both, or the restored + (larger) image ends up in the wrong place. +*/ +/** + @brief DiagramImageItem::crop + Context-menu action: opens ImageCropDialog against m_base_pixmap + (the true, uncropped original), pre-populated with whatever crop + rectangle was chosen in a previous session -- re-editable, not + destructive: nothing about the original content is ever discarded, + only which region of it is currently being shown, exactly the same + principle setTransparentColor() already follows for its own choices. + Recomputes pixmap_ via computeDisplayPixmap() so any already-picked + transparent colours are correctly re-applied to the newly-cropped + region, rather than lost (the crop dialog itself knows nothing + about them). + + pos() also needs adjusting, not just pixmap_: setPixmap() (called + via the "pixmap" undo command below) recomputes + transformOriginPoint() from the new boundingRect(), but pos() itself + is untouched by that -- without fixing it up here too, the + surviving content would visually jump to wherever local (0,0) ends + up after the crop rect changes, rather than staying exactly where + it already was. This has to work whether this is the first crop + ever applied or an adjustment of an existing one, so the position + math is always done relative to the CURRENT crop rect (m_crop_rect, + before it's updated below) -- when there's no previous crop, that's + simply the whole base, which is what the very first version of this + method assumed unconditionally. + + Chained into one undo step together with the pixmap change, since + undoing a crop has to restore both, or the restored (larger) image + ends up in the wrong place. +*/ +void DiagramImageItem::crop() +{ + if (!diagram() || diagram()->isReadOnly()) + return; + + QWidget *parentWidget = diagram()->views().isEmpty() ? nullptr : diagram()->views().first(); + ImageCropDialog dialog(m_base_pixmap, m_crop_rect, parentWidget); + if (dialog.exec() != QDialog::Accepted) + return; + + const QRect newCropRect = dialog.cropRect(); + if (newCropRect.isEmpty() || newCropRect == m_crop_rect) + return; // nothing actually changed + + // newCropRect is in m_base_pixmap's own coordinates; converting its + // center into the CURRENT local space (pixmap_'s own coordinates, + // i.e. relative to the OLD m_crop_rect's own top-left) before + // mapping to the scene through the transform that's still active + // right now, prior to anything below changing it. + const QPointF newCropCenterInCurrentLocal = QRectF(newCropRect).center() - QPointF(m_crop_rect.topLeft()); + const QPointF cropCenterScene = mapToScene(newCropCenterInCurrentLocal); + const QPointF oldPos = pos(); + + const QPixmap oldPixmap = pixmap_; + const QPixmap newPixmap = computeDisplayPixmap(m_base_pixmap, newCropRect, m_transparent_colors, m_transparent_tolerance); + m_crop_rect = newCropRect; + + // boundingRect() is exactly QRectF(pixmap_.rect()) (confirmed by + // reading the actual implementation, not assumed) -- so the new + // origin point can be derived directly from newPixmap here. Unlike + // before this class gained a proper transform, setPixmap() no + // longer manages the pivot automatically at all (see its own + // comment for why) -- crop() now has to set it explicitly itself, + // chained into the same undo step as the pixmap and position + // changes, since all three genuinely change together here. + const QPointF oldPivot = m_transform.pivot; + const QPointF newOriginPoint = QRectF(newPixmap.rect()).center(); + const QPointF newPos = cropCenterScene - newOriginPoint; + + auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap); + undo->setText(tr("Rogner une image")); + new QPropertyUndoCommand(this, "pos", oldPos, newPos, undo); + new QPropertyUndoCommand(this, "rawPivot", oldPivot, newOriginPoint, undo); + m_pivotIsCustom = false; + diagram()->undoStack().push(undo); +} diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index f983eb3b5..f59d419e1 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -19,9 +19,16 @@ #define DIAGRAM_IMAGE_ITEM_H #include "qetgraphicsitem.h" +#include "shapetransform.h" + +#include +#include +#include class QDomElement; class QDomDocument; +class QGraphicsSceneContextMenuEvent; +class QetGraphicsHandlerItem; /** This class represents a selectable, movable and editable image on a @@ -30,6 +37,23 @@ class QDomDocument; */ class DiagramImageItem : public QetGraphicsItem { Q_OBJECT + Q_PROPERTY(QPixmap pixmap READ pixmap WRITE setPixmap NOTIFY pixmapChanged) + Q_PROPERTY(qreal scaleFactorX READ scaleFactorX WRITE setScaleFactorX NOTIFY transformChanged) + Q_PROPERTY(qreal scaleFactorY READ scaleFactorY WRITE setScaleFactorY NOTIFY transformChanged) + Q_PROPERTY(qreal rotationAngle READ rotationAngle WRITE setRotationAngle NOTIFY transformChanged) + Q_PROPERTY(qreal skewX READ skewX WRITE setSkewX NOTIFY transformChanged) + Q_PROPERTY(qreal skewY READ skewY WRITE setSkewY NOTIFY transformChanged) + Q_PROPERTY(QPointF pivot READ pivot WRITE setPivot NOTIFY transformChanged) + // A second, deliberately non-compensating property on the SAME + // underlying value -- setPivot() (above) intentionally adjusts + // pos() to keep the image visually in place, which is exactly + // wrong for crop()'s own undo chain: crop() already computes the + // correct final pos() itself (accounting for the crop, not just + // the pivot move) and pushes it as its own separate "pos" command, + // so a chained "pivot" step going through the compensating setter + // would silently overwrite that already-correct pos() a second + // time. rawPivot exists solely for that one caller. + Q_PROPERTY(QPointF rawPivot READ pivot WRITE setPivotRaw NOTIFY transformChanged) // constructors, destructor public: @@ -40,6 +64,19 @@ class DiagramImageItem : public QetGraphicsItem { // attributes public: enum { Type = UserType + 1007 }; + + // A deliberately smaller, image-specific set than QetShapeItem's own + // HandleMode/HandleRole: images have no path/polygon/arc concepts at + // all, so reusing those enums directly would pull in a great deal of + // irrelevant baggage for no benefit. RotateSkew's math (rotate, + // skew, and their shared scaleAndShearOffset()/scaleOnlyOffset() + // helpers) mirrors QetShapeItem's own identical formulas -- adapted + // to this class's own, simpler 4-corner/4-edge/1-pivot handle set, + // 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 }; // methods public: @@ -54,13 +91,108 @@ class DiagramImageItem : public QetGraphicsItem { virtual QDomElement toXml(QDomDocument &) const; void editProperty() override; void setPixmap(const QPixmap &pixmap); + QPixmap pixmap() const { return pixmap_; } QRectF boundingRect() const override; QString name() const override; - + + qreal scaleFactorX() const { return m_transform.scaleX; } + qreal scaleFactorY() const { return m_transform.scaleY; } + void setScaleFactorX(qreal factor); + void setScaleFactorY(qreal factor); + qreal rotationAngle() const { return m_transform.rotation; } + void setRotationAngle(qreal angle); + qreal skewX() const { return m_transform.skewX; } + void setSkewX(qreal skew); + qreal skewY() const { return m_transform.skewY; } + void setSkewY(qreal skew); + QPointF pivot() const { return m_transform.pivot; } + void setPivot(const QPointF &pivot); + void setPivotRaw(const QPointF &pivot); + + signals: + void pixmapChanged(); + void transformChanged(); + protected: void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override; - + void contextMenuEvent(QGraphicsSceneContextMenuEvent *event) override; + void mousePressEvent(QGraphicsSceneMouseEvent *event) override; + void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; + void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override; + bool sceneEventFilter(QGraphicsItem *watched, QEvent *event) override; + QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; + + private: + void replaceImage(); + void mirror(bool horizontal); + void setTransparentColor(); + void crop(); + void restoreAspectRatio(); + static QPixmap computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList &colors, int tolerance); + + void toggleHandleMode(); + HandleMode nextHandleMode() const; + static QString handleModeLabel(HandleMode mode); + void updateModeHint(); + void refreshInteractionHints(); + QString currentModeStatusHint() const; + void rebuildHandles(); + void repositionHandles(); + void clearHandles(); + void resetPivotToBoundingRectCenter(); + void handlerMousePressEvent(int index, Qt::KeyboardModifiers mods); + void handlerMouseMoveEvent(int index, QGraphicsSceneMouseEvent *event); + void handlerMouseReleaseEvent(int index); + 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); + void dragPivot(const QPointF &localPos); + QPointF scaleOnlyOffset(const QPointF &localPoint) const; + QPointF scaleAndShearOffset(const QPointF &localPoint) const; + QPointF handlePosition(int index) const; + static QPointF cornerPosition(int cornerIndex, qreal w, qreal h); + static QPointF edgeMidpointPosition(int edgeIndex, qreal w, qreal h); + static QColor colorForHandleRole(HandleRole role); + static QString hintForHandleRole(HandleRole role); + void showStatusHint(const QString &text) const; + void clearStatusHint() const; + protected: QPixmap pixmap_; + // The true, pristine original -- never itself cropped or colour- + // keyed. pixmap_ (the displayed result) is always re-derived from + // this plus m_crop_rect and m_transparent_colors/tolerance, via + // computeDisplayPixmap(). Without keeping this separate, re-opening + // either the crop or transparency dialog after using the other + // would show an already-modified image as if it were the source -- + // areas already cropped away or coloured out would be gone for + // good, with no way to recover or adjust them, only start over. + // Updated by whatever genuinely replaces or reorients the image's + // actual content (construction, replaceImage(), and mirror(), which + // also mirrors m_crop_rect to keep referring to the same region of + // the now-flipped base) -- never by crop() or setTransparentColor() + // themselves, which only ever change which subset of this base is + // shown. + QPixmap m_base_pixmap; + QRect m_crop_rect; // relative to m_base_pixmap; equals m_base_pixmap.rect() when nothing has been cropped + QList m_transparent_colors; + int m_transparent_tolerance = 10; + + // Independent scaleX/scaleY here is the actual point of this whole + // member: QGraphicsItem::scale() is a single, uniform float, which + // is exactly why an image could never break its own aspect ratio + // before this. skewX/skewY exist in the struct but are never + // written by anything below -- deliberately deferred, not an + // oversight (see the .cpp for why). + ShapeTransform m_transform; + bool m_pivotIsCustom = false; + HandleMode m_handleMode = HandleMode::Size; + QVector m_handler_vector; + 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 + ShapeTransform m_original_transform; + bool m_deferHandleReposition = false; // see setPivot()'s comment + bool m_resizeCenterAnchored = false; // decided once, at press time -- see handlerMousePressEvent()'s comment for why, mirroring the identical fix already made for shape creation }; #endif diff --git a/sources/qetgraphicsitem/qetshapeitem.cpp b/sources/qetgraphicsitem/qetshapeitem.cpp index 1fe149ab3..a744cf772 100644 --- a/sources/qetgraphicsitem/qetshapeitem.cpp +++ b/sources/qetgraphicsitem/qetshapeitem.cpp @@ -25,16 +25,28 @@ #include "../diagramview.h" #include "../qet.h" #include "../qeticons.h" +#include "../qetapp.h" +#include "../qetdiagrameditor.h" #include "../qetxml.h" #include "../ui/shapegraphicsitempropertieswidget.h" #include "../utils/qetutils.h" +#include "../undocommand/promoteshapecommand.h" + +#include +#include +#include +#include +#include +#include +#include +#include /** @brief QetShapeItem::QetShapeItem Constructor of shape item. point 1 and 2 must be in scene coordinate @param p1 first point @param p2 second point - @param type type of item (line, rectangle, ellipse) + @param type type of item (line, rectangle, ellipse, polygon, path) @param parent parent item */ QetShapeItem::QetShapeItem(QPointF p1, QPointF p2, ShapeType type, QGraphicsItem *parent) : @@ -48,6 +60,7 @@ QetShapeItem::QetShapeItem(QPointF p1, QPointF p2, ShapeType type, QGraphicsItem setFlags(QGraphicsItem::ItemIsMovable | QGraphicsItem::ItemIsSelectable | QGraphicsItem::ItemSendsGeometryChanges); setAcceptHoverEvents(true); m_pen.setStyle(Qt::SolidLine); + m_transform.pivot = localRect().center(); //ensure handlers are always above this item connect(this, &QetShapeItem::zChanged, [this]() { @@ -61,7 +74,6 @@ QetShapeItem::QetShapeItem(QPointF p1, QPointF p2, ShapeType type, QGraphicsItem m_remove_point = new QAction(tr("Supprimer ce point"), this); m_remove_point->setIcon(QET::Icons::Remove); connect(m_remove_point, &QAction::triggered, this, &QetShapeItem::removePoint); - } QetShapeItem::~QetShapeItem() @@ -115,6 +127,21 @@ void QetShapeItem::setP2(const QPointF &P2) prepareGeometryChange(); m_P2 = P2; } + else + { + return; // nothing actually changed + } + // setP2() is what drives the live "second point under the cursor" + // preview while a new shape is being drawn (see + // diagrameventaddshape.cpp) -- every other geometry setter already + // keeps the pivot following the center until it's customized; this + // one was missing it, which is why a freshly click-and-dragged shape + // could end up with its pivot frozen at the very first click point + // (a degenerate, zero-size starting rect/line) instead of the + // shape's actual center once it was drawn out. + if (!m_pivotIsCustom) + resetPivotToBoundingRectCenter(); + emit geometryChanged(); } /** @@ -129,7 +156,10 @@ bool QetShapeItem::setLine(const QLineF &line) prepareGeometryChange(); m_P1 = line.p1(); m_P2 = line.p2(); - adjustHandlerPos(); + if (!m_pivotIsCustom) + resetPivotToBoundingRectCenter(); + repositionHandles(); + emit geometryChanged(); return true; } @@ -146,7 +176,10 @@ bool QetShapeItem::setRect(const QRectF &rect) prepareGeometryChange(); m_P1 = rect.topLeft(); m_P2 = rect.bottomRight(); - adjustHandlerPos(); + if (!m_pivotIsCustom) + resetPivotToBoundingRectCenter(); + repositionHandles(); + emit geometryChanged(); return true; } @@ -166,18 +199,22 @@ bool QetShapeItem::setPolygon(const QPolygonF &polygon) } prepareGeometryChange(); m_polygon = polygon; - adjustHandlerPos(); + if (!m_pivotIsCustom) + resetPivotToBoundingRectCenter(); + repositionHandles(); + emit geometryChanged(); return true; } /** @brief QetShapeItem::setClosed - Close this item, have effect only if this item is a polygon. + Close this item -- has effect for Polygon and Path only (the two + shape types with a genuine open/closed distinction at all). @param close */ void QetShapeItem::setClosed(bool close) { - if (m_shapeType == Polygon && close != m_closed) + if ((m_shapeType == Polygon || m_shapeType == Path) && close != m_closed) { prepareGeometryChange(); m_closed = close; @@ -189,7 +226,7 @@ void QetShapeItem::setXRadius(qreal X) { m_xRadius = X; update(); - adjustHandlerPos(); + repositionHandles(); emit XRadiusChanged(); } @@ -197,10 +234,204 @@ void QetShapeItem::setYRadius(qreal Y) { m_yRadius = Y; update(); - adjustHandlerPos(); + repositionHandles(); emit YRadiusChanged(); } +/** + @brief QetShapeItem::setRotation + @param degrees + One property per ShapeTransform scalar: every handle (and the + properties panel) touches exactly one of these, never the matrix + directly. See shapetransform.h for why the matrix itself is only ever + built from these, never mutated by hand. +*/ +void QetShapeItem::setRotation(qreal degrees) +{ + if (qFuzzyCompare(m_transform.rotation, degrees)) return; + prepareGeometryChange(); + m_transform.rotation = degrees; + setTransform(m_transform.toMatrix()); + emit transformChanged(); +} + +void QetShapeItem::setSkewX(qreal degrees) +{ + if (qFuzzyCompare(m_transform.skewX, degrees)) return; + prepareGeometryChange(); + m_transform.skewX = degrees; + setTransform(m_transform.toMatrix()); + emit transformChanged(); +} + +void QetShapeItem::setSkewY(qreal degrees) +{ + if (qFuzzyCompare(m_transform.skewY, degrees)) return; + prepareGeometryChange(); + m_transform.skewY = degrees; + setTransform(m_transform.toMatrix()); + emit transformChanged(); +} + +void QetShapeItem::setScaleFactorX(qreal factor) +{ + if (qFuzzyCompare(m_transform.scaleX, factor)) return; + prepareGeometryChange(); + m_transform.scaleX = factor; + setTransform(m_transform.toMatrix()); + emit transformChanged(); +} + +void QetShapeItem::setScaleFactorY(qreal factor) +{ + if (qFuzzyCompare(m_transform.scaleY, factor)) return; + prepareGeometryChange(); + m_transform.scaleY = factor; + setTransform(m_transform.toMatrix()); + emit transformChanged(); +} + +/** + @brief QetShapeItem::setPivot + Move the pivot point. pos() is adjusted at the same time so the shape + never visibly jumps -- see compensatedPositionForNewPivot() in + shapetransform.h for why that adjustment is needed at all. + @param newPivot in local coordinates +*/ +void QetShapeItem::setPivot(const QPointF &newPivot) +{ + if (m_transform.pivot == newPivot) return; + const QPointF newPos = compensatedPositionForNewPivot( + pos(), m_transform.pivot, newPivot, m_transform.linearPart()); + prepareGeometryChange(); + m_transform.pivot = newPivot; + // Apply both related updates, then reposition handles exactly once + // using the fully-updated state. Without this guard, setTransform() + // alone would trigger a reposition using the new transform but the + // still-old pos() (or vice versa if the calls were swapped), which is + // a real, if usually imperceptible, inconsistent intermediate state -- + // removing it outright is cheaper than reasoning about whether it's + // ever visible. + m_deferHandleReposition = true; + setTransform(m_transform.toMatrix()); + // QetGraphicsItem::setPos() (our own base class) silently re-snaps its + // argument to the grid before applying it -- appropriate for an + // ordinary user drag of the whole shape, but wrong here: newPos is an + // exact compensation value derived from rotation trigonometry, so it's + // essentially never grid-aligned, and snapping it introduces real + // rounding error on every single frame of a pivot drag. Over a + // multi-frame drag that accumulates into a clearly visible drift of + // the whole rectangle -- confirmed by simulation: reproducing the + // grid-snap here produced tens of units of drift over a normal drag; + // calling Qt's own, non-snapping setPos() directly produced exactly + // zero. Grid-snapping the *pivot itself* (done earlier, in + // handlerMouseMoveEvent, on the mouse position) is still exactly + // right -- it's only this internal, precision-critical compensation + // step that must bypass it. + QGraphicsItem::setPos(newPos); + m_deferHandleReposition = false; + repositionHandles(); + emit transformChanged(); +} + +void QetShapeItem::resetPivotToBoundingRectCenter() +{ + m_pivotIsCustom = false; + setPivot(localRect().center()); +} + +/** + @brief QetShapeItem::enableNodeEditMode + Switches into NodeEdit mode, so every node's control handles (and + their tangent guide lines drawn in paint()) become visible -- the + same visual feedback normal editing already gets via the context + menu's node-kind actions, made available to the pen tool too so a + node's handles are visible *as they're being dragged into + existence*, not only afterward. +*/ +void QetShapeItem::enableNodeEditMode() +{ + if (m_shapeType != Path) + return; + m_handleMode = HandleMode::NodeEdit; + rebuildHandles(); +} + +/** + @brief QetShapeItem::setStartAngle / setEndAngle + Only meaningful for Ellipse. Dragging one endpoint onto the other + (span within 5 degrees of a full turn) snaps back to a full ellipse -- + this is the whole answer to "how do I turn an arc back into a closed + ellipse": there is no separate Arc type to convert out of. +*/ +// Dragging one endpoint until it nearly touches the other closes the +// ellipse back up -- checked on the *geometric* (mod 360) proximity of +// the two angles, not the raw stored span, since dragArcEndpoint() +// deliberately keeps the raw span continuous/unwrapped (it can legally +// exceed +-360 during a drag) rather than snapping it into a fixed +// range on every frame. +static bool anglesGeometricallyAdjacent(qreal span) +{ + qreal wrapped = std::fmod(span, 360.0); + if (wrapped < 0) wrapped += 360.0; + return wrapped < 5.0 || wrapped > 355.0; +} + +void QetShapeItem::setStartAngle(qreal degrees) +{ + if (qFuzzyCompare(m_startAngle, degrees)) return; + prepareGeometryChange(); + m_startAngle = degrees; + if (anglesGeometricallyAdjacent(spanAngle())) { m_startAngle = 0; m_endAngle = 360; } + repositionHandles(); + emit arcChanged(); +} + +void QetShapeItem::setEndAngle(qreal degrees) +{ + if (qFuzzyCompare(m_endAngle, degrees)) return; + prepareGeometryChange(); + m_endAngle = degrees; + if (anglesGeometricallyAdjacent(spanAngle())) { m_startAngle = 0; m_endAngle = 360; } + repositionHandles(); + emit arcChanged(); +} + +void QetShapeItem::setArcClosure(ArcClosure closure) +{ + if (m_arcClosure == closure) return; + prepareGeometryChange(); + m_arcClosure = closure; + emit arcChanged(); +} + +/** + @brief QetShapeItem::setPathNodes + Replace the node list of a Path shape. Interactive editing of anchors + and control handles both go through the handle roles built by + rebuildHandles() (PathAnchor always visible; PathControlIn/Out for + the active node once double-click enters node-edit mode) -- this + setter itself is just the plain data replacement underneath that. +*/ +void QetShapeItem::setPathNodes(const QVector &nodes) +{ + prepareGeometryChange(); + m_nodes = nodes; + if (!m_pivotIsCustom) + resetPivotToBoundingRectCenter(); + // Always a full rebuild, not just a reposition: unlike the drag + // handlers (dragPathAnchor, dragPathControlHandle), which mutate + // m_nodes in place and are called from a handle's own mouse-move + // event -- where destroying that handle mid-gesture would be a real + // problem -- this setter is only ever called from outside that + // pipeline (currently: the pen tool, adding a new node with every + // click). The node *count* routinely changes here, and + // repositionHandles() has no way to notice that on its own; it only + // moves whatever handles already exist. + rebuildHandles(); + emit geometryChanged(); +} + /** @brief QetShapeItem::pointCount @return the number of point in the polygon @@ -240,7 +471,6 @@ void QetShapeItem::removePoints(int number) i++; prepareGeometryChange(); m_polygon.pop_back(); - setTransformOriginPoint(boundingRect().center()); } while (i < number); } @@ -251,14 +481,49 @@ void QetShapeItem::removePoints(int number) */ QRectF QetShapeItem::boundingRect() const { - return shape().boundingRect().adjusted(-6, -6, 6, 6); + QRectF rect = shape().boundingRect().adjusted(-6, -6, 6, 6); + + if (m_shapeType == Path && m_handleMode == HandleMode::NodeEdit) + { + for (const PathNode &n : m_nodes) + { + if (n.inHandle) + rect |= QRectF(n.anchor, n.anchor + *n.inHandle).normalized().adjusted(-6, -6, 6, 6); + if (n.outHandle) + rect |= QRectF(n.anchor, n.anchor + *n.outHandle).normalized().adjusted(-6, -6, 6, 6); + } + } + + return rect; } /** - @brief QetShapeItem::shape - @return the shape of this item + @brief QetShapeItem::localRect + The rect used for corner/edge handle placement and for arc/radius + math. For Polygon and Path this is the vertex bounding box; for + everything else it is simply the P1/P2 rect, as before. */ -QPainterPath QetShapeItem::shape() const +QRectF QetShapeItem::localRect() const +{ + if (m_shapeType == Polygon) + return m_polygon.boundingRect(); + if (m_shapeType == Path) + { + QPolygonF anchors; + for (const PathNode &n : m_nodes) anchors << n.anchor; + return anchors.boundingRect(); + } + return QRectF(m_P1, m_P2).normalized(); +} + +/** + @brief QetShapeItem::outline + The raw, unstroked path for the current type, in local coordinates. + Shared by shape() (which strokes it for hit-testing/selection) and + paint() (which draws it directly) so the two can never disagree about + what the shape actually looks like. +*/ +QPainterPath QetShapeItem::outline() const { QPainterPath path; @@ -268,29 +533,85 @@ QPainterPath QetShapeItem::shape() const path.moveTo(m_P1); path.lineTo(m_P2); break; + case Rectangle: - path.addRoundedRect( - QRectF(m_P1, m_P2), - m_xRadius, - m_yRadius); + path.addRoundedRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius); break; + case Ellipse: - path.addEllipse(QRectF(m_P1, m_P2)); + { + const QRectF r(QRectF(m_P1, m_P2)); + if (isFullEllipse()) + { + path.addEllipse(r); + } + else if (m_arcClosure == Pie) + { + path.moveTo(r.center()); + path.arcTo(r, m_startAngle, spanAngle()); + path.closeSubpath(); + } + else + { + path.arcMoveTo(r, m_startAngle); + path.arcTo(r, m_startAngle, spanAngle()); + if (m_arcClosure == Chord) + path.closeSubpath(); + } break; + } + case Polygon: path.addPolygon(m_polygon); if (m_closed) { path.closeSubpath(); } break; + + case Path: + if (!m_nodes.isEmpty()) + { + path.moveTo(m_nodes.first().anchor); + for (int i = 1; i < m_nodes.size(); ++i) + { + const PathNode &prev = m_nodes.at(i - 1); + const PathNode &cur = m_nodes.at(i); + if (prev.outHandle || cur.inHandle) + path.cubicTo( + prev.anchor + prev.outHandle.value_or(QPointF()), + cur.anchor + cur.inHandle.value_or(QPointF()), + cur.anchor); + else + path.lineTo(cur.anchor); + } + if (m_closed && m_nodes.size() > 1) + { + const PathNode &last = m_nodes.last(); + const PathNode &first = m_nodes.first(); + if (last.outHandle || first.inHandle) + path.cubicTo( + last.anchor + last.outHandle.value_or(QPointF()), + first.anchor + first.inHandle.value_or(QPointF()), + first.anchor); + path.closeSubpath(); + } + } + break; } + return path; +} + +/** + @brief QetShapeItem::shape + @return the shape of this item +*/ +QPainterPath QetShapeItem::shape() const +{ QPainterPathStroker pps; pps.setWidth(m_hovered? m_pen.widthF()+10 : m_pen.widthF()); pps.setJoinStyle(Qt::RoundJoin); - path = pps.createStroke(path); - - return (path); + return pps.createStroke(outline()); } /** @@ -325,15 +646,88 @@ void QetShapeItem::paint( painter -> restore (); } - switch (m_shapeType) + painter->drawPath(outline()); + + // Segment midpoint markers: a small, distinct diamond at the middle + // of every segment -- straight or already curved -- shown only in + // NodeEdit mode. Purely a discoverability aid: a straight run + // between two Corner nodes otherwise gives no visual hint at all + // that it's draggable (see mousePressEvent()'s curve-drag + // detection), which is exactly what made that interaction easy to + // miss. Deliberately *not* a real handle -- the actual drag already + // works from anywhere along the segment via nearestPathSegment(), + // not just this exact point, and turning the marker into its own + // discrete hit target would only narrow that back down. Diamond + // shape and a muted colour distinguish it at a glance from the + // round, brighter anchor/control dots, which are real handles. + // Drawn before the guide lines/handles on purpose, so those stay + // visually on top of this rather than the reverse. + if (m_shapeType == Path && m_handleMode == HandleMode::NodeEdit) { - case Line: painter->drawLine(QLineF(m_P1, m_P2)); break; - case Rectangle: painter->drawRoundedRect(QRectF(m_P1, m_P2), - m_xRadius, - m_yRadius); break; - case Ellipse: painter->drawEllipse(QRectF(m_P1, m_P2)); break; - case Polygon: m_closed ? painter->drawPolygon(m_polygon) - : painter->drawPolyline(m_polygon); break; + const int count = m_nodes.size(); + const int segments = m_closed ? count : count - 1; + if (segments > 0) + { + painter->save(); + QPen markerPen(QColor(180, 120, 40)); + markerPen.setWidthF(1.2); + markerPen.setCosmetic(true); + painter->setPen(markerPen); + painter->setBrush(QColor(255, 210, 130, 200)); + + for (int i = 0; i < segments; ++i) + { + const PathNode &a = m_nodes.at(i); + const PathNode &b = m_nodes.at((i + 1) % count); + const QPointF p0 = a.anchor; + const QPointF p1 = a.anchor + a.outHandle.value_or(QPointF()); + const QPointF p2 = b.anchor + b.inHandle.value_or(QPointF()); + const QPointF p3 = b.anchor; + + const qreal t = 0.5, u = 0.5; + const QPointF mid = u*u*u*p0 + 3*u*u*t*p1 + 3*u*t*t*p2 + t*t*t*p3; + + const qreal r = 3.5; // half-diagonal, in local units + QPolygonF diamond; + diamond << QPointF(mid.x(), mid.y() - r) + << QPointF(mid.x() + r, mid.y()) + << QPointF(mid.x(), mid.y() + r) + << QPointF(mid.x() - r, mid.y()); + painter->drawPolygon(diamond); + } + painter->restore(); + } + } + + // Tangent guide lines: connects each visible control handle back to + // its anchor, for every node that has any -- deliberately not + // filtered down to "just one node" (see the header's HandleMode + // comment): for the small, decorative curves this editor actually + // deals with, seeing every handle at once removes a click's worth of + // friction per node, and is worth the trade-off even if it would get + // busy on a much larger, hand-traced path. + // Cosmetic on purpose, unlike the shape's own stroke: this is a UI + // aid, not artwork, so it should stay a constant screen width + // regardless of zoom or the shape's own skew -- the same reasoning + // that already makes the handle dots themselves ItemIgnoresTransformations. + if (m_shapeType == Path && m_handleMode == HandleMode::NodeEdit) + { + for (const PathNode &n : m_nodes) + { + if (!n.inHandle && !n.outHandle) + continue; + painter->save(); + QPen guidePen(QColor(120, 120, 120)); + guidePen.setStyle(Qt::DashLine); + guidePen.setWidthF(1.0); + guidePen.setCosmetic(true); + painter->setPen(guidePen); + if (n.inHandle) + painter->drawLine(n.anchor, n.anchor + *n.inHandle); + if (n.outHandle) + painter->drawLine(n.anchor, n.anchor + *n.outHandle); + painter->restore(); + } } painter->restore(); @@ -347,6 +741,7 @@ void QetShapeItem::paint( void QetShapeItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event) { m_hovered = true; + refreshInteractionHints(); QetGraphicsItem::hoverEnterEvent(event); } @@ -358,20 +753,132 @@ void QetShapeItem::hoverEnterEvent(QGraphicsSceneHoverEvent *event) void QetShapeItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event) { m_hovered = false; + clearStatusHint(); QetGraphicsItem::hoverLeaveEvent(event); } +/** + @brief QetShapeItem::mousePressEvent + A left click on an *already selected* shape (without having dragged) + toggles between the Size and RotateSkew handle sets -- the same + convention LibreOffice Draw and PowerPoint use. A click that performs + the selection itself does not toggle, so selecting a shape always + starts in Size mode. +*/ void QetShapeItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { + // Grabbing the curve itself (not a handle -- those are separate + // QetGraphicsHandlerItems and take precedence automatically, since + // Qt only delivers this event here when the click missed all of + // them) reshapes the segment under the cursor -- but only once the + // press actually turns into a drag. A plain click here (press+release + // with no real movement) is exactly the same gesture that cycles the + // handle mode elsewhere, and NodeEdit mode's own click-on-the-curve + // hit-test matches almost any click that landed on the shape at all + // -- so deciding "reshape vs. cycle" at press time would make it + // impossible to ever click onward to RotateSkew once in NodeEdit. + // Deferred to mouseMoveEvent/mouseReleaseEvent instead, below. + if (m_shapeType == Path && m_handleMode == HandleMode::NodeEdit && event->button() == Qt::LeftButton) + { + const auto hit = nearestPathSegment(event->pos()); + if (hit.first >= 0) + { + const int count = m_nodes.size(); + const PathNode &a = m_nodes.at(hit.first); + const PathNode &b = m_nodes.at((hit.first + 1) % count); + m_curveDragSegment = hit.first; + m_curveDragT = hit.second; + m_curveDragOriginalP1 = a.anchor + a.outHandle.value_or(QPointF()); + m_curveDragOriginalP2 = b.anchor + b.inHandle.value_or(QPointF()); + m_curveDragPressPos = event->pos(); + m_curveDragEngaged = false; + m_old_nodes = m_nodes; + event->accept(); + return; + } + } + + const bool wasAlreadySelected = isSelected(); event->ignore(); QetGraphicsItem::mousePressEvent(event); - if (event->button() == Qt::LeftButton) { - switchResizeMode(); + if (event->button() == Qt::LeftButton) + { + if (wasAlreadySelected && isSelected()) + toggleHandleMode(); event->accept(); } } +/** + @brief QetShapeItem::mouseMoveEvent + Only ever does something different from the base class while a + curve-segment drag (started in mousePressEvent above) is pending or + active; otherwise this is exactly QetGraphicsItem's own whole-shape- + move handling (grid-snapped drag, multi-selection movement via + diagram()->elementsMover()), untouched. +*/ +void QetShapeItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) +{ + if (m_curveDragSegment >= 0) + { + if (!m_curveDragEngaged) + { + if (QLineF(m_curveDragPressPos, event->pos()).length() < 3.0) + return; // still just a (so far) plain click -- wait and see, don't reshape yet + m_curveDragEngaged = true; + } + + QPointF scenePos = event->scenePos(); + if (!(event->modifiers() & Qt::ControlModifier)) + scenePos = Diagram::snapToGrid(scenePos); + dragCurveSegment(m_curveDragSegment, m_curveDragT, mapFromScene(scenePos)); + event->accept(); + return; + } + QetGraphicsItem::mouseMoveEvent(event); +} + +/** + @brief QetShapeItem::mouseReleaseEvent + If the press in mousePressEvent never turned into a real drag, this + was just a plain click -- cycle the handle mode, exactly like a click + anywhere else on an already-selected shape would. Otherwise commit + the curve-drag's undo entry, reusing the same generic before/after + XML snapshot mechanism as every other Path edit. With no curve-drag + pending at all, this defers entirely to QetGraphicsItem's own release + handling (which ends the whole-shape-move gesture via elementsMover()). +*/ +void QetShapeItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) +{ + if (m_curveDragSegment >= 0) + { + if (m_curveDragEngaged) + { + if (m_nodes != m_old_nodes && diagram()) + { + const QVector after = m_nodes; + m_nodes = m_old_nodes; + const QDomElement before = snapshotXml(); + m_nodes = after; + const QDomElement afterXml = snapshotXml(); + auto *undo = new PromoteShapeCommand(this, before, afterXml); + undo->setText(tr("Déformer une courbe")); + diagram()->undoStack().push(undo); + } + } + else + { + toggleHandleMode(); + } + m_curveDragSegment = -1; + m_curveDragEngaged = false; + event->accept(); + return; + } + QetGraphicsItem::mouseReleaseEvent(event); +} + /** @brief QetShapeItem::itemChange @param change @@ -384,8 +891,8 @@ QVariant QetShapeItem::itemChange(QGraphicsItem::GraphicsItemChange change, if (change == ItemSelectedHasChanged) { if (value.toBool() == true) { - //If this is selected, wa add handlers. - addHandler(); + //If this is selected, we add handlers. + rebuildHandles(); } else //Else this is deselected, we remove handlers { @@ -393,12 +900,22 @@ QVariant QetShapeItem::itemChange(QGraphicsItem::GraphicsItemChange change, { qDeleteAll(m_handler_vector); m_handler_vector.clear(); + m_handleRoles.clear(); + m_handleSlot.clear(); } - m_resize_mode = 1; + // Same reasoning as toggleHandleMode(): resetting the mode + // changes boundingRect()'s coverage, so Qt needs to be told + // before it happens, not after -- this is exactly the path + // that left a stale guide line behind when deselecting a + // Path with a far-dragged handle still active. + prepareGeometryChange(); + m_handleMode = HandleMode::Size; } + refreshInteractionHints(); } - else if (change == ItemPositionHasChanged) { - adjustHandlerPos(); + else if (change == ItemPositionHasChanged || change == ItemTransformHasChanged) { + if (!m_deferHandleReposition) + repositionHandles(); } else if (change == ItemSceneHasChanged) { @@ -431,19 +948,38 @@ bool QetShapeItem::sceneEventFilter(QGraphicsItem *watched, QEvent *event) { if(event->type() == QEvent::GraphicsSceneMousePress) //Click { - handlerMousePressEvent(); + handlerMousePressEvent(m_vector_index); return true; } else if(event->type() == QEvent::GraphicsSceneMouseMove) //Move { - handlerMouseMoveEvent(static_cast(event)); + handlerMouseMoveEvent(m_vector_index, static_cast(event)); return true; } else if (event->type() == QEvent::GraphicsSceneMouseRelease) //Release { - handlerMouseReleaseEvent(); + handlerMouseReleaseEvent(m_vector_index); return true; } + else if (event->type() == QEvent::GraphicsSceneHoverEnter) + { + // Handle-specific status bar text, on top of the + // tooltip Qt shows natively from the handle's own + // setToolTip() (see rebuildHandles()) -- returning + // false leaves that native tooltip handling alone. + showStatusHint(handleRoleTooltip(m_handleRoles.value(m_vector_index), m_handleSlot.value(m_vector_index))); + return false; + } + else if (event->type() == QEvent::GraphicsSceneHoverLeave) + { + // Falls back to the shape's own general hint (if the + // cursor is still over the shape's body overall) + // rather than clearing outright -- leaving one + // handle's small hit area shouldn't blank the status + // bar if you're still hovering the shape itself. + refreshInteractionHints(); + return false; + } } } } @@ -459,7 +995,10 @@ void QetShapeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { m_context_menu_pos = event->pos(); - if (m_shapeType == QetShapeItem::Polygon) + const bool canConvertToPath = (m_shapeType == Rectangle || m_shapeType == Ellipse); + + if (m_shapeType == QetShapeItem::Polygon || m_shapeType == QetShapeItem::Path + || m_shapeType == QetShapeItem::Line || canConvertToPath) { if (diagram()->selectedItems().isEmpty()) { this->setSelected(true); @@ -483,20 +1022,94 @@ void QetShapeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) if (d_view) { QScopedPointer menu(new QMenu()); - menu.data()->addAction(m_insert_point); - if (m_handler_vector.count() > 2) + if (m_shapeType == QetShapeItem::Polygon) { - for (QetGraphicsHandlerItem *qghi : m_handler_vector) + menu.data()->addAction(m_insert_point); + + if (m_handler_vector.count() > 2) { - if (qghi->contains(qghi->mapFromScene(event->scenePos()))) + for (QetGraphicsHandlerItem *qghi : m_handler_vector) { - menu.data()->addAction(m_remove_point); - break; + if (qghi->contains(qghi->mapFromScene(event->scenePos()))) + { + menu.data()->addAction(m_remove_point); + break; + } } } } + if (m_shapeType == QetShapeItem::Path && !m_nodes.isEmpty()) + { + // Nearest node to the click, not "whichever tiny + // handle you managed to hit exactly" -- the latter + // is finicky for a right-click (no drag feedback + // to correct your aim), and silently found nothing + // once NodeEdit mode added PathControlIn/Out + // handles right next to the anchor, which this + // menu never checked for. + const QPointF localClick = mapFromScene(event->scenePos()); + int nearest = 0; + qreal bestDist = -1; + for (int i = 0; i < m_nodes.size(); ++i) + { + const QPointF d = m_nodes.at(i).anchor - localClick; + const qreal dist = d.x() * d.x() + d.y() * d.y(); + if (bestDist < 0 || dist < bestDist) { bestDist = dist; nearest = i; } + } + + const std::pair segmentHit = nearestPathSegment(localClick); + if (segmentHit.first >= 0) + { + const int seg = segmentHit.first; + const qreal t = segmentHit.second; + QAction *insertAct = menu.data()->addAction(tr("Ajouter un point")); + connect(insertAct, &QAction::triggered, this, [this, seg, t]() { insertPathPoint(seg, t); }); + } + + QMenu *nodeMenu = menu.data()->addMenu(tr("Nœud le plus proche")); + QAction *toSmooth = nodeMenu->addAction(tr("Lisse")); + QAction *toSymmetric = nodeMenu->addAction(tr("Symétrique")); + QAction *toCorner = nodeMenu->addAction(tr("Anguleux")); + + auto *group = new QActionGroup(nodeMenu); + for (QAction *a : {toSmooth, toSymmetric, toCorner}) + { + a->setCheckable(true); + a->setActionGroup(group); + } + const NodeKind currentKind = m_nodes.at(nearest).kind; + toSmooth->setChecked(currentKind == NodeKind::Smooth); + toSymmetric->setChecked(currentKind == NodeKind::Symmetric); + toCorner->setChecked(currentKind == NodeKind::Corner); + + connect(toSmooth, &QAction::triggered, this, [this, nearest]() { setNodeKind(nearest, NodeKind::Smooth); }); + connect(toSymmetric, &QAction::triggered, this, [this, nearest]() { setNodeKind(nearest, NodeKind::Symmetric); }); + connect(toCorner, &QAction::triggered, this, [this, nearest]() { setNodeKind(nearest, NodeKind::Corner); }); + + if (m_nodes.size() > 2) + { + QAction *removeAct = menu.data()->addAction(tr("Supprimer le nœud le plus proche")); + connect(removeAct, &QAction::triggered, this, [this, nearest]() { removePathPoint(nearest); }); + } + } + + if (canConvertToPath) + { + QAction *convert = menu.data()->addAction(tr("Convertir en polyligne")); + connect(convert, &QAction::triggered, this, &QetShapeItem::convertToPathExplicitly); + } + + QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal")); + QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical")); + connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); }); + connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); }); + + menu.data()->addSeparator(); + QAction *properties = menu.data()->addAction(tr("Propriétés...")); + connect(properties, &QAction::triggered, this, &QetShapeItem::editProperty); + menu.data()->addSeparator(); menu.data()->addActions(d_view->contextMenuActions()); menu.data()->exec(event->screenPos()); @@ -511,148 +1124,562 @@ void QetShapeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) } /** - @brief QetShapeItem::switchResizeMode + @brief QetShapeItem::toggleHandleMode + Cycled by clicking an already-selected shape: Size -> Corner -> + RotateSkew -> Size for Rectangle (the only type with a corner-radius + concept); Size -> RotateSkew -> Size for everything else, skipping the + Corner state entirely rather than showing an empty/meaningless one. */ -void QetShapeItem::switchResizeMode() +/** + @brief QetShapeItem::nextHandleMode + What clicking an already-selected shape switches to from the current + mode -- the single source of truth for the cycle order, shared by + toggleHandleMode() (which acts on it) and updateModeHint() (which + just describes it). +*/ +QetShapeItem::HandleMode QetShapeItem::nextHandleMode() const { - if (m_shapeType == Ellipse) + if (m_shapeType == Rectangle) { - if (m_resize_mode == 1) - { - m_resize_mode = 2; - for (QetGraphicsHandlerItem *qghi : m_handler_vector) { - qghi->setColor(Qt::darkGreen); + if (m_handleMode == HandleMode::Size) return HandleMode::Corner; + if (m_handleMode == HandleMode::Corner) return HandleMode::RotateSkew; + return HandleMode::Size; + } + if (m_shapeType == Path) + { + if (m_handleMode == HandleMode::Size) return HandleMode::NodeEdit; + if (m_handleMode == HandleMode::NodeEdit) return HandleMode::RotateSkew; + return HandleMode::Size; + } + return (m_handleMode == HandleMode::Size) ? HandleMode::RotateSkew : HandleMode::Size; +} + +void QetShapeItem::toggleHandleMode() +{ + // Changing m_handleMode changes what boundingRect() covers (it only + // includes nodes' guide-line extents while in NodeEdit mode) -- + // without this, leaving NodeEdit with a far-flung handle would leave + // a rendering ghost behind, the same class of bug already fixed + // elsewhere for other state changes that affect boundingRect(). + prepareGeometryChange(); + m_handleMode = nextHandleMode(); + rebuildHandles(); + refreshInteractionHints(); +} + +/** + @brief QetShapeItem::handleModeLabel + Short, human name for a HandleMode -- used to build both the tooltip + ("click: switches to X") and, indirectly, the status bar text. +*/ +QString QetShapeItem::handleModeLabel(HandleMode mode) +{ + switch (mode) + { + case HandleMode::Size: return tr("Taille"); + case HandleMode::Corner: return tr("Coins arrondis"); + case HandleMode::NodeEdit: return tr("Édition des nœuds"); + case HandleMode::RotateSkew: return tr("Rotation/Inclinaison"); + } + return QString(); +} + +/** + @brief QetShapeItem::updateModeHint + Keeps the tooltip in sync with what the next click would do -- called + on selection change (so it appears/disappears with the handles + themselves) and after every mode switch. Deliberately short: this is + a tooltip, not documentation -- the fuller gesture/modifier reference + lives in the status bar instead (see currentModeStatusHint(), + hoverEnterEvent()), which has room for it without popping up + uninvited. +*/ +void QetShapeItem::updateModeHint() +{ + setToolTip(isSelected() + ? tr("Cliquer : mode %1").arg(handleModeLabel(nextHandleMode())) + : QString()); +} + +/** + @brief QetShapeItem::refreshInteractionHints + Keeps the tooltip text current, and -- if the shape is already being + hovered -- immediately re-shows both the tooltip and the status bar + hint rather than leaving them stuck on whatever was true before. + Needed because Qt only re-evaluates a tooltip, and this class only + re-shows the status bar, when the cursor *moves*: selecting a shape + (often clicked while the mouse was already sitting on it) or cycling + handle modes (definitely clicked while sitting on it) both change + what should be shown without the cursor moving at all, so without + this both would appear stale until the user moved away and back. +*/ +void QetShapeItem::refreshInteractionHints() +{ + updateModeHint(); + + if (!m_hovered || !isSelected()) + return; + + const QString status = currentModeStatusHint(); + const QString tip = toolTip(); + + // Deferred to the next event-loop iteration rather than shown + // immediately: this is called from within the same mousePressEvent + // that changed the mode, and Qt hides any visible tooltip as part of + // its own click handling -- racing an immediate re-show against that + // is exactly what made the tooltip appear inconsistently. Letting + // Qt's own click handling finish first, then re-showing, is the + // standard fix for this class of "act after the current event has + // settled" timing problem. Re-checks hover/selection on firing since + // they're cheap and the world could in principle have changed in the + // meantime, however unlikely at a zero-millisecond delay. + QTimer::singleShot(0, this, [this, status, tip]() + { + if (!m_hovered || !isSelected()) + return; + showStatusHint(status); + if (!tip.isEmpty()) + QToolTip::showText(QCursor::pos(), tip); + }); +} + +void QetShapeItem::showStatusHint(const QString &text) const +{ + if (text.isEmpty() || !diagram() || diagram()->views().isEmpty()) + return; + if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst())) + editor->statusBar()->showMessage(text); +} + +void QetShapeItem::clearStatusHint() const +{ + if (!diagram() || diagram()->views().isEmpty()) + return; + if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst())) + editor->statusBar()->clearMessage(); +} + +/** + @brief QetShapeItem::currentModeStatusHint + One-line reference for whatever handles are visible right now, + shown in the status bar while hovering a selected shape's body (see + hoverEnterEvent()) -- the modifier keys in particular (Ctrl, Shift, + Alt) have no other visible indication that they do anything at all. + Also carries the same "next mode" information as the tooltip, since + the status bar has room for the full picture in one place rather + than needing the tooltip read separately. +*/ +QString QetShapeItem::currentModeStatusHint() const +{ + QString hint; + switch (m_handleMode) + { + case HandleMode::Size: + if (m_shapeType == Rectangle || m_shapeType == Ellipse) + { + hint = tr("Glisser un coin/bord : redimensionner " + "(Ctrl = depuis le centre, Maj = proportions, Alt = détacher en polyligne)"); + if (m_shapeType == Ellipse) + hint += tr(" ; point turquoise : arc"); } - } - else - { - m_resize_mode = 1; - for (QetGraphicsHandlerItem *qghi : m_handler_vector) { - qghi->setColor(Qt::blue); + else if (m_shapeType == Line) + { + hint = tr("Glisser une extrémité : la déplacer"); } + else + { + hint = tr("Glisser un point : le déplacer"); + } + break; + + case HandleMode::Corner: + hint = tr("Glisser le point violet : arrondir les coins"); + break; + + case HandleMode::NodeEdit: + hint = tr("Glisser une poignée ou la courbe : déformer (Alt = briser la tangente) ; " + "Alt+glisser un point anguleux : créer des poignées ; " + "clic droit : menu du nœud le plus proche"); + break; + + case HandleMode::RotateSkew: + { + // Only Rectangle/Ellipse actually have SkewEdge handles in + // this mode (see rebuildHandles()) -- Line/Polygon/Path + // don't, so mentioning "un bord : inclinaison" for them + // would describe a handle that doesn't exist. + const QString handleWord = (m_shapeType == Line) ? tr("une extrémité") + : (m_shapeType == Rectangle || m_shapeType == Ellipse) ? tr("un coin") + : tr("un point"); + hint = tr("Glisser %1 : rotation (Maj = 15°)").arg(handleWord); + if (m_shapeType == Rectangle || m_shapeType == Ellipse) + hint += tr(" ; un bord : inclinaison"); + hint += tr(" ; point rouge : glisser pour repositionner le centre de rotation"); + break; } } - else if (m_shapeType == Rectangle) - { - if (m_resize_mode == 1) - { - m_resize_mode = 2; - for (QetGraphicsHandlerItem *qghi : m_handler_vector) - qghi->setColor(Qt::darkGreen); - } - else if (m_resize_mode == 2) - { - m_resize_mode = 3; - qDeleteAll(m_handler_vector); - m_handler_vector.clear(); - addHandler(); - for (QetGraphicsHandlerItem *qghi : m_handler_vector) { - qghi->setColor(Qt::magenta); - } - } - else if (m_resize_mode == 3) - { - m_resize_mode = 1; - qDeleteAll(m_handler_vector); - m_handler_vector.clear(); - addHandler(); - for (QetGraphicsHandlerItem *qghi : m_handler_vector) { - qghi->setColor(Qt::blue); - } + // Mentioned once here rather than repeated in every branch above: + // Ctrl means "free positioning, no grid snap" uniformly for every + // handle and for dragging the curve itself (see + // handlerMouseMoveEvent() and mouseMoveEvent()'s curve-drag branch), + // so it isn't really a property of any one mode. + if (!hint.isEmpty()) + { + hint += tr(" (Ctrl pendant le glissement = position libre, sans accrochage à la grille)"); + hint += tr(" — Cliquer : mode %1").arg(handleModeLabel(nextHandleMode())); + } + + return hint; +} + +/** + @brief QetShapeItem::handleRoleTooltip + Set natively on each handle item in rebuildHandles() -- Qt shows a + handle's own tooltip in preference to the shape's when hovering + directly over it, so this is what gives each handle its own distinct + hint instead of every one of them repeating the shape's general + "click: next mode" tooltip regardless of which handle you're actually + looking at. +*/ +QString QetShapeItem::handleRoleTooltip(HandleRole role, int slot) const +{ + // Ctrl always means "free positioning, no grid snap" here -- checked + // once, uniformly, before any role-specific dispatch even runs (see + // handlerMouseMoveEvent()) -- so it belongs on every one of these, + // not just the handles where it also happens to do something extra + // (Resize's center-anchor). + switch (role) + { + case HandleRole::Resize: + { + // Line's endpoints use this same role but ignore mods + // entirely (see dragResize()'s early-return for Line) -- + // no modifier applies to them at all. + if (m_shapeType == Line) + return tr("Glisser : déplacer ce point"); + QString text = tr("Glisser : redimensionner (Ctrl = depuis le centre + position libre, Maj = proportions"); + if (isResizeCornerSlot(slot)) + text += tr(", Alt = détacher en polyligne"); + text += ")"; + return text; } + case HandleRole::Rotate: + return tr("Glisser : rotation (Ctrl = position libre, Maj = 15°)"); + case HandleRole::SkewEdge: + return tr("Glisser : inclinaison (Ctrl = position libre, Maj = 15°)"); + case HandleRole::Pivot: + return tr("Glisser : repositionner le centre de rotation (Ctrl = position libre)"); + case HandleRole::CornerRadius: + return tr("Glisser : arrondir les coins (Ctrl = position libre)"); + case HandleRole::ArcEndpoint: + return tr("Glisser : ajuster l'arc (Ctrl = position libre, Maj = 15°)"); + case HandleRole::PathAnchor: + { + QString text = tr("Glisser : déplacer le point (Ctrl = position libre"); + if (m_shapeType == Path) + text += tr(", Alt = créer des poignées"); + text += ")"; + return text; + } + case HandleRole::PathControlIn: + case HandleRole::PathControlOut: + return tr("Glisser : déformer la courbe (Ctrl = position libre, Alt = briser la tangente)"); + } + return QString(); +} + + +/** + @brief QetShapeItem::colorForHandleRole + Purely cosmetic, but consistent with the existing convention of + color-coding what a handle currently does. Mirror-resize used to be + its own colored click-cycled mode (green); it is now a Ctrl modifier + available on every Resize handle, so only genuinely distinct + behaviours get their own color here. +*/ +QColor QetShapeItem::colorForHandleRole(HandleRole role) +{ + switch (role) + { + case HandleRole::Resize: return Qt::blue; + case HandleRole::Rotate: return Qt::darkGreen; + case HandleRole::SkewEdge: return Qt::darkYellow; + case HandleRole::Pivot: return Qt::red; + case HandleRole::CornerRadius: return Qt::magenta; + case HandleRole::ArcEndpoint: return Qt::darkCyan; + case HandleRole::PathAnchor: return Qt::blue; + case HandleRole::PathControlIn: + case HandleRole::PathControlOut:return Qt::gray; + } + return Qt::blue; +} + +QPointF QetShapeItem::cornerPoint(const QRectF &rect, int cornerIndex) +{ + switch (cornerIndex & 3) + { + case 0: return rect.topLeft(); + case 1: return rect.topRight(); + case 2: return rect.bottomRight(); + default: return rect.bottomLeft(); } } -void QetShapeItem::addHandler() +QPointF QetShapeItem::rotateHandleReference(int slot) const { - if (m_handler_vector.isEmpty()) + if (m_shapeType == Line) + return (slot == 0) ? m_P1 : m_P2; + if (m_shapeType == Polygon) + return m_polygon.value(slot); + if (m_shapeType == Path) + return (slot < m_nodes.size()) ? m_nodes.at(slot).anchor : QPointF(); + return cornerPoint(localRect(), slot); +} + +QPointF QetShapeItem::edgeMidpoint(const QRectF &rect, int edgeIndex) +{ + switch (edgeIndex & 3) { - QVector points_vector; - switch (m_shapeType) - { - case Line: - points_vector << m_P1 << m_P2; - break; - case Rectangle: - if (m_resize_mode == 3) { - points_vector = QetGraphicsHandlerUtility::pointForRadiusRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius); - } - else { - points_vector = QetGraphicsHandlerUtility::pointsForRect(QRectF(m_P1, m_P2)); - } - break; - case Ellipse: - points_vector = QetGraphicsHandlerUtility::pointsForRect(QRectF(m_P1, m_P2)); - break; - case Polygon: - points_vector = m_polygon; - break; - } - - if(!points_vector.isEmpty() && scene()) - { - m_handler_vector = QetGraphicsHandlerItem::handlerForPoint(mapToScene(points_vector), QETUtils::graphicsHandlerSize(this)); - - for(const auto handler : std::as_const(m_handler_vector)) - { - handler->setZValue(this->zValue()+1); - handler->setColor(Qt::blue); - scene()->addItem(handler); - handler->installSceneEventFilter(this); - } - } + case 0: return QPointF(rect.center().x(), rect.top()); + case 1: return QPointF(rect.right(), rect.center().y()); + case 2: return QPointF(rect.center().x(), rect.bottom()); + default: return QPointF(rect.left(), rect.center().y()); } } /** - @brief QetShapeItem::adjustHandlerPos - Adjust the position of the handler item + @brief QetShapeItem::handlePositionFor + Local-coordinate position for one (role, slot) pair, given the + shape's *current* geometry. This is the single source of truth used + both to build handles from scratch (rebuildHandles()) and to move + existing ones during a live drag (repositionHandles()) -- the two + can never disagree about where a handle belongs. */ -void QetShapeItem::adjustHandlerPos() +QPointF QetShapeItem::handlePositionFor(HandleRole role, int slot) const { - if (m_handler_vector.isEmpty()) { - return; - } + const QRectF r = localRect(); - QVector points_vector; - switch (m_shapeType) + switch (role) { - case Line: { - points_vector << m_P1 << m_P2; - break; - } - case Rectangle: { - if (m_resize_mode != 3) { - points_vector = QetGraphicsHandlerUtility::pointsForRect(QRectF(m_P1, m_P2)); - } - else { - points_vector = QetGraphicsHandlerUtility::pointForRadiusRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius); - } - break; - } - case Ellipse: { - points_vector = QetGraphicsHandlerUtility::pointsForRect(QRectF(m_P1, m_P2)); - break; - } - case Polygon: { - points_vector = m_polygon; - break; - } - } + case HandleRole::Resize: + if (m_shapeType == Line) + return (slot == 0) ? m_P1 : m_P2; + return QetGraphicsHandlerUtility::pointsForRect(r).value(slot); - if (m_handler_vector.size() == points_vector.size()) - { - points_vector = mapToScene(points_vector); - for (int i = 0 ; i < points_vector.size() ; ++i) - m_handler_vector.at(i)->setPos(points_vector.at(i)); + case HandleRole::Rotate: + return rotateHandleReference(slot); + + case HandleRole::SkewEdge: + return edgeMidpoint(r, slot); + + case HandleRole::Pivot: + return m_transform.pivot; + + case HandleRole::CornerRadius: + return QetGraphicsHandlerUtility::pointForRadiusRect(r, m_xRadius, m_yRadius).value(slot); + + case HandleRole::ArcEndpoint: + return QetGraphicsHandlerUtility::pointsForArc(r, m_startAngle, spanAngle()).value(slot); + + case HandleRole::PathAnchor: + if (m_shapeType == Polygon) + return m_polygon.value(slot); + return (slot < m_nodes.size()) ? m_nodes.at(slot).anchor : QPointF(); + + case HandleRole::PathControlIn: + return (slot < m_nodes.size() && m_nodes.at(slot).inHandle) + ? m_nodes.at(slot).anchor + *m_nodes.at(slot).inHandle : QPointF(); + case HandleRole::PathControlOut: + return (slot < m_nodes.size() && m_nodes.at(slot).outHandle) + ? m_nodes.at(slot).anchor + *m_nodes.at(slot).outHandle : QPointF(); } - else + return QPointF(); +} + +QVector QetShapeItem::currentHandlePositions() const +{ + QVector positions; + positions.reserve(m_handleRoles.size()); + for (int i = 0; i < m_handleRoles.size(); ++i) + positions << handlePositionFor(m_handleRoles.at(i), m_handleSlot.at(i)); + return positions; +} + +/** + @brief QetShapeItem::scaleOnlyOffset / scaleAndShearOffset + A local point's offset from the pivot, run through *only* the parts of + the linear transform that are not currently being edited by a drag -- + i.e. the parts that stay fixed while the user is dragging one specific + handle. These are the fixed reference values dragRotateHandle() and + dragSkewHandle() solve against, instead of round-tripping through + mapFromScene() (which would divide out the very parameter being + solved for and turn the drag into a feedback loop -- see + dragRotateHandle()'s comment for the concrete failure mode this + replaces). +*/ +QPointF QetShapeItem::scaleOnlyOffset(const QPointF &localPoint) const +{ + const QPointF offset = localPoint - m_transform.pivot; + return QPointF(offset.x() * m_transform.scaleX, offset.y() * m_transform.scaleY); +} + +QPointF QetShapeItem::scaleAndShearOffset(const QPointF &localPoint) const +{ + const QPointF scaled = scaleOnlyOffset(localPoint); + const qreal kx = qTan(qDegreesToRadians(m_transform.skewX)); + const qreal ky = qTan(qDegreesToRadians(m_transform.skewY)); + return QPointF(scaled.x() + kx * scaled.y(), scaled.y() + ky * scaled.x()); +} + +/** + @brief QetShapeItem::rebuildHandles + (Re)creates the handler items from scratch, for the current + shapeType()/handleMode(). Only called when the *set* of handles + changes -- selection, mode toggle, node count change -- never during + a live drag, since that would delete the very QetGraphicsHandlerItem + currently receiving the mouse-move events. See repositionHandles() + for the drag-safe alternative. +*/ +void QetShapeItem::rebuildHandles() +{ + if (!m_handler_vector.isEmpty()) { qDeleteAll(m_handler_vector); m_handler_vector.clear(); - addHandler(); } + m_handleRoles.clear(); + m_handleSlot.clear(); + + auto addRole = [this](HandleRole role, int slot) { + m_handleRoles << role; + m_handleSlot << slot; + }; + + switch (m_shapeType) + { + case Line: + if (m_handleMode == HandleMode::Size) + { + addRole(HandleRole::Resize, 0); + addRole(HandleRole::Resize, 1); + } + else // RotateSkew: rotate around the pivot; skewing a + // zero-height line isn't a meaningful operation + { + addRole(HandleRole::Rotate, 0); + addRole(HandleRole::Rotate, 1); + addRole(HandleRole::Pivot, 0); + } + break; + + case Rectangle: + case Ellipse: + if (m_handleMode == HandleMode::Size) + { + for (int i = 0; i < 8; ++i) addRole(HandleRole::Resize, i); + } + else if (m_handleMode == HandleMode::Corner) + { + // Rectangle only -- toggleHandleMode() never selects this + // state for Ellipse, so this branch is a no-op for it. + for (int i = 0; i < 2; ++i) addRole(HandleRole::CornerRadius, i); + } + else // RotateSkew + { + for (int i = 0; i < 4; ++i) addRole(HandleRole::Rotate, i); + for (int i = 0; i < 4; ++i) addRole(HandleRole::SkewEdge, i); + addRole(HandleRole::Pivot, 0); + } + if (m_shapeType == Ellipse) + for (int i = 0; i < 2; ++i) addRole(HandleRole::ArcEndpoint, i); + break; + + case Polygon: + if (m_handleMode == HandleMode::Size) + { + for (int i = 0; i < m_polygon.size(); ++i) + addRole(HandleRole::PathAnchor, i); + } + else // RotateSkew: any vertex can be dragged to rotate the + // whole polygon around the pivot; skewing doesn't have + // an obvious "which edge" convention for an arbitrary + // vertex count the way it does for a rectangle's 4 + // fixed edges, so it's left out here too. + { + for (int i = 0; i < m_polygon.size(); ++i) + addRole(HandleRole::Rotate, i); + addRole(HandleRole::Pivot, 0); + } + break; + + case Path: + if (m_handleMode == HandleMode::Size) + { + for (int i = 0; i < m_nodes.size(); ++i) + addRole(HandleRole::PathAnchor, i); + } + else if (m_handleMode == HandleMode::NodeEdit) + { + for (int i = 0; i < m_nodes.size(); ++i) + { + addRole(HandleRole::PathAnchor, i); + const PathNode &n = m_nodes.at(i); + if (n.inHandle) addRole(HandleRole::PathControlIn, i); + if (n.outHandle) addRole(HandleRole::PathControlOut, i); + } + } + else // RotateSkew + { + for (int i = 0; i < m_nodes.size(); ++i) + addRole(HandleRole::Rotate, i); + addRole(HandleRole::Pivot, 0); + } + break; + } + + if (m_handleRoles.isEmpty() || !scene()) + return; + + const QVector positions = currentHandlePositions(); + m_handler_vector = QetGraphicsHandlerItem::handlerForPoint(mapToScene(positions), QETUtils::graphicsHandlerSize(this)); + + for (int i = 0; i < m_handler_vector.size(); ++i) + { + QetGraphicsHandlerItem *h = m_handler_vector.at(i); + h->setZValue(zValue() + 1); + h->setColor(colorForHandleRole(m_handleRoles.at(i))); + h->setToolTip(handleRoleTooltip(m_handleRoles.at(i), m_handleSlot.at(i))); + h->setAcceptHoverEvents(true); + scene()->addItem(h); + h->installSceneEventFilter(this); + } +} + +/** + @brief QetShapeItem::repositionHandles + Moves the *existing* handler items to match current geometry, without + touching their identity or which one is mid-drag. Safe (and expected) + to be called on every frame of a live drag. Falls back to a full + rebuild only if the handle count has somehow drifted out of sync -- + this should not normally happen, since only rebuildHandles() ever + changes m_handleRoles/m_handleSlot. +*/ +void QetShapeItem::repositionHandles() +{ + if (m_handler_vector.isEmpty()) + return; + + const QVector positions = currentHandlePositions(); + if (positions.size() != m_handler_vector.size()) + { + rebuildHandles(); + return; + } + + const QVector scenePositions = mapToScene(positions); + for (int i = 0; i < scenePositions.size(); ++i) + m_handler_vector.at(i)->setPos(scenePositions.at(i)); } void QetShapeItem::insertPoint() @@ -705,17 +1732,754 @@ void QetShapeItem::removePoint() } /** - @brief QetShapeItem::handlerMousePressEvent - @param qghi - @param event + @brief QetShapeItem::snapshotXml + toXml() into a private, throwaway document -- used only to hand a + self-contained QDomElement to PromoteShapeCommand, which deep-clones + it again into its own document anyway (see promoteshapecommand.cpp). */ -void QetShapeItem::handlerMousePressEvent() +QDomElement QetShapeItem::snapshotXml() const { + QDomDocument doc; + QDomElement e = toXml(doc); + doc.appendChild(e); + return e; +} + +/** + @brief QetShapeItem::promoteRectangleOrEllipseToPolygon + Alt+drag on a Resize corner detaches that one vertex, at the moment + the shape stops being a Rectangle/Ellipse and becomes a Polygon. The + prior state (type, geometry, transform, style) is captured by + PromoteShapeCommand so a single Undo restores it exactly -- this fact + never touches the saved .qet file, only the session's undo stack. + @param detachedResizeIndex the Resize-role slot (0,2,5,7 = corners in + QetGraphicsHandlerUtility::pointsForRect's own ordering) being dragged + @param newLocalPos where that corner is being dragged to +*/ +void QetShapeItem::promoteRectangleOrEllipseToPolygon(int detachedResizeIndex, const QPointF &newLocalPos) +{ + const QDomElement before = snapshotXml(); + + QPolygonF corners; + const QRectF r = localRect(); + corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft(); + + // pointsForRect corner slots (0,2,5,7) map onto our 0..3 corner order. + static const QHash resizeSlotToCorner = {{0,0}, {2,1}, {5,3}, {7,2}}; + const int cornerIndex = resizeSlotToCorner.value(detachedResizeIndex, 0); + corners[cornerIndex] = newLocalPos; + + prepareGeometryChange(); + m_shapeType = Polygon; + m_polygon = corners; + m_closed = true; + + const QDomElement after = snapshotXml(); + + if (diagram()) + { + auto *undo = new PromoteShapeCommand(this, before, after); + diagram()->undoStack().push(undo); + } + + rebuildHandles(); +} + +/** + @brief QetShapeItem::nearestPathSegment + Nearest point on the whole curve to localPos, found by coarse sampling + each segment's cubic Bezier (24 samples is plenty for a context-menu + pick -- this only has to be close enough to feel right, not exact). + Returns {-1, 0} if there are fewer than two nodes to form a segment. +*/ +std::pair QetShapeItem::nearestPathSegment(const QPointF &localPos) const +{ + const int count = m_nodes.size(); + if (count < 2) + return std::make_pair(-1, 0.0); + + const int segments = m_closed ? count : count - 1; + int bestSegment = -1; + qreal bestT = 0.0; + qreal bestDistSq = -1; + + for (int i = 0; i < segments; ++i) + { + const PathNode &a = m_nodes.at(i); + const PathNode &b = m_nodes.at((i + 1) % count); + const QPointF p0 = a.anchor; + const QPointF p1 = a.anchor + a.outHandle.value_or(QPointF()); + const QPointF p2 = b.anchor + b.inHandle.value_or(QPointF()); + const QPointF p3 = b.anchor; + + const int samples = 24; + for (int s = 0; s <= samples; ++s) + { + const qreal t = qreal(s) / samples; + const qreal u = 1 - t; + const QPointF pt = u*u*u*p0 + 3*u*u*t*p1 + 3*u*t*t*p2 + t*t*t*p3; + const QPointF d = pt - localPos; + const qreal distSq = d.x() * d.x() + d.y() * d.y(); + if (bestDistSq < 0 || distSq < bestDistSq) + { + bestDistSq = distSq; + bestSegment = i; + bestT = t; + } + } + } + return {bestSegment, bestT}; +} + +/** + @brief QetShapeItem::insertPathPoint + Splits the cubic Bezier between node segmentIndex and its successor at + parameter t, via De Casteljau's algorithm -- the two resulting halves + are guaranteed to retrace the original curve exactly (no kink at the + seam), which a naive "just add a point at this position and guess new + handles" approach cannot promise. When neither side of the segment + actually has a handle (a plain straight run between two Corner-ish + points), this degrades to a plain linear split with no handles at all + on the new node, rather than introducing phantom zero-effect handles + on what the user sees as a straight line. +*/ +void QetShapeItem::insertPathPoint(int segmentIndex, qreal t) +{ + const QDomElement before = snapshotXml(); + + prepareGeometryChange(); + const int count = m_nodes.size(); + const int nextIndex = (segmentIndex + 1) % count; + PathNode &a = m_nodes[segmentIndex]; + PathNode &b = m_nodes[nextIndex]; + + const QPointF p0 = a.anchor; + const QPointF p1 = a.anchor + a.outHandle.value_or(QPointF()); + const QPointF p2 = b.anchor + b.inHandle.value_or(QPointF()); + const QPointF p3 = b.anchor; + + PathNode mid; + if (!a.outHandle && !b.inHandle) + { + mid.anchor = p0 + (p3 - p0) * t; + mid.kind = NodeKind::Corner; + } + else + { + const QPointF p01 = p0 + (p1 - p0) * t; + const QPointF p12 = p1 + (p2 - p1) * t; + const QPointF p23 = p2 + (p3 - p2) * t; + const QPointF p012 = p01 + (p12 - p01) * t; + const QPointF p123 = p12 + (p23 - p12) * t; + const QPointF p0123 = p012 + (p123 - p012) * t; + + mid.anchor = p0123; + mid.kind = NodeKind::Smooth; // De Casteljau guarantees tangent continuity through the split point + mid.inHandle = p012 - p0123; + mid.outHandle = p123 - p0123; + + a.outHandle = p01 - p0; + b.inHandle = p23 - p3; + } + + // Insert right before b's current position -- except when the split + // segment is the closed path's wrap-around (last node back to + // first): inserting there at the *end* of the array places the new + // node correctly between old-last and old-first without shifting + // every other node's index. + m_nodes.insert(nextIndex == 0 ? m_nodes.size() : nextIndex, mid); + + const QDomElement after = snapshotXml(); + if (diagram()) + { + auto *undo = new PromoteShapeCommand(this, before, after); + undo->setText(tr("Ajouter un point à une courbe")); + diagram()->undoStack().push(undo); + } + + rebuildHandles(); +} + +/** + @brief QetShapeItem::removePathPoint + Deletes a node outright and lets its two former neighbours connect + directly using their own existing handles -- no attempt to re-fit a + single curve that approximates the old shape through where the point + used to be. That's a much harder (and inherently lossy) problem; this + is the same plain "just delete it" convention most editors default to. +*/ +void QetShapeItem::removePathPoint(int nodeIndex) +{ + if (nodeIndex < 0 || nodeIndex >= m_nodes.size() || m_nodes.size() <= 2) + return; + + const QDomElement before = snapshotXml(); + + prepareGeometryChange(); + m_nodes.removeAt(nodeIndex); + + const QDomElement after = snapshotXml(); + if (diagram()) + { + auto *undo = new PromoteShapeCommand(this, before, after); + undo->setText(tr("Supprimer un point d'une courbe")); + diagram()->undoStack().push(undo); + } + + rebuildHandles(); +} + +void QetShapeItem::convertToPathExplicitly() +{ + if (m_shapeType != Rectangle && m_shapeType != Ellipse) + return; + + const QDomElement before = snapshotXml(); + + QPolygonF corners; + const QRectF r = localRect(); + corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft(); + + prepareGeometryChange(); + m_shapeType = Polygon; + m_polygon = corners; + m_closed = true; + + const QDomElement after = snapshotXml(); + + if (diagram()) + { + auto *undo = new PromoteShapeCommand(this, before, after); + undo->setText(tr("Convertir %1 en polyligne").arg(name())); + diagram()->undoStack().push(undo); + } + + rebuildHandles(); +} + +/** + @brief QetShapeItem::mirror + Flips the shape around its own current pivot -- horizontal negates + scaleFactorX, vertical negates scaleFactorY, composing naturally with + whatever rotation/skew are already set rather than needing any + shape-specific geometry logic. Verified numerically before building + this: the full transform (rotation+skew+scale combined) round-trips + exactly under a negative scale just as it does under a positive one, + since a negative-scale matrix is just as invertible (non-zero + determinant) -- and the two interactions that seemed most likely to + break under mirroring turned out not to: dragRotateHandle()'s + angle-solve isn't hardcoded to assume positive scale (it correctly + flips sign for the mirrored case and still tracks the mouse exactly), + and dragArcEndpoint() operates entirely in local space via + mapFromScene(), which is exactly as exact under a mirror as without + one. isResizeCornerSlot() is also unaffected on inspection: it + identifies corners by their fixed *local* index, which mirroring + never changes -- only where those indices end up on screen. +*/ +/** + @brief QetShapeItem::mirror + Flips the shape around its own current pivot. + + The first version of this just negated scaleFactorX/Y directly, + leaving rotation and skew untouched -- correct only when both happen + to already be zero. Reflection doesn't commute with rotation + (reflect . rotate(t) = rotate(-t) . reflect) or with shear, so on + anything already rotated or skewed that version silently reflected + the shape's *original, pre-transform* geometry and then re-applied + the same rotation on top -- visibly changing a line's inclination + instead of mirroring it, exactly as reported. + + Fixed by building the reflection as an actual matrix applied to the + CURRENT linear transform (reflecting the shape's current on-screen + appearance, not its original geometry), then decomposing the result + back into the five scalar fields via decomposeLinear() -- the same + function already used for importing a foreign matrix, applied here + to a structurally identical problem: a matrix exists, scalars that + reproduce it are needed. Verified against the real QTransform and + decomposeLinear() on a shape that was both rotated and skewed before + relying on it here, not just reasoned about abstractly. +*/ +void QetShapeItem::mirror(bool horizontal) +{ + if (!diagram()) + return; + + const QTransform reflect = horizontal + ? QTransform(-1, 0, 0, 1, 0, 0) + : QTransform(1, 0, 0, -1, 0, 0); + const QTransform newLinear = m_transform.linearPart() * reflect; + ShapeTransform candidate = decomposeLinear(newLinear); + candidate.pivot = m_transform.pivot; + + // decomposeLinear() becomes numerically unreliable for very extreme + // skew (found by stress-testing 100k random configurations: solid + // through +/-45 deg, some failures starting around +/-50-60 deg and + // beyond -- a pre-existing limitation of that shared function, not + // something specific to mirroring, but worth guarding against here + // rather than risk silently producing a visibly wrong shape for a + // skew angle far more extreme than normal use would ever reach. + // Verify the decomposition actually reproduces the intended matrix + // before committing to it, rather than trust it unconditionally. + const QTransform rebuilt = candidate.linearPart(); + const qreal err = qMax(qMax(qAbs(rebuilt.m11() - newLinear.m11()), qAbs(rebuilt.m12() - newLinear.m12())), + qMax(qAbs(rebuilt.m21() - newLinear.m21()), qAbs(rebuilt.m22() - newLinear.m22()))); + if (err > 1e-3) + { + if (!diagram()->views().isEmpty()) + { + if (auto *editor = QETApp::diagramEditorAncestorOf(diagram()->views().constFirst())) + editor->statusBar()->showMessage(tr("Miroir impossible : inclinaison trop extrême pour cette forme"), 4000); + } + return; + } + + const QDomElement before = snapshotXml(); + + prepareGeometryChange(); + m_transform = candidate; + setTransform(m_transform.toMatrix()); + emit transformChanged(); + + const QDomElement after = snapshotXml(); + + auto *undo = new PromoteShapeCommand(this, before, after); + undo->setText(horizontal ? tr("Miroir horizontal de %1").arg(name()) : tr("Miroir vertical de %1").arg(name())); + diagram()->undoStack().push(undo); +} + +/** + @brief QetShapeItem::setNodeKind + Change a Path node's kind, via the context menu rather than a drag. + Promoting a Corner node to Smooth or Symmetric synthesizes whichever + handles it doesn't already have, from its neighbours -- direction + toward the far neighbour, length a third of the distance to the near + one, the same simple heuristic most vector editors use for a "make + smooth" action. Demoting to Corner leaves any existing handles + untouched (a Corner node can still have handles -- see + dragPathControlHandle() -- it just stops forcing them to stay + linked). Also switches into NodeEdit mode, so the result is + immediately visible rather than a change to data you'd otherwise + have to click into node-edit mode again to see. +*/ +void QetShapeItem::setNodeKind(int nodeIndex, NodeKind kind) +{ + if (nodeIndex < 0 || nodeIndex >= m_nodes.size()) + return; + + const QDomElement before = snapshotXml(); + + prepareGeometryChange(); + PathNode &node = m_nodes[nodeIndex]; + node.kind = kind; + + if (kind != NodeKind::Corner) + { + const int count = m_nodes.size(); + const bool hasPrev = (nodeIndex > 0) || (m_closed && count > 1); + const bool hasNext = (nodeIndex < count - 1) || (m_closed && count > 1); + const QPointF prevAnchor = hasPrev ? m_nodes.at((nodeIndex - 1 + count) % count).anchor : node.anchor; + const QPointF nextAnchor = hasNext ? m_nodes.at((nodeIndex + 1) % count).anchor : node.anchor; + + QPointF tangent = nextAnchor - prevAnchor; + const qreal tangentLength = qSqrt(tangent.x() * tangent.x() + tangent.y() * tangent.y()); + if (tangentLength > 1e-6) + tangent /= tangentLength; + + if (!node.outHandle && hasNext) + { + const QPointF toNext = nextAnchor - node.anchor; + const qreal len = qSqrt(toNext.x() * toNext.x() + toNext.y() * toNext.y()) / 3.0; + node.outHandle = tangent * len; + } + if (!node.inHandle && hasPrev) + { + const QPointF toPrev = prevAnchor - node.anchor; + const qreal len = qSqrt(toPrev.x() * toPrev.x() + toPrev.y() * toPrev.y()) / 3.0; + node.inHandle = -tangent * len; + } + + if (kind == NodeKind::Symmetric && node.inHandle && node.outHandle) + { + // Equalize lengths so the two handles are true mirrors from + // the start, not just collinear -- otherwise a node promoted + // straight to Symmetric would look identical to Smooth until + // the next drag "fixed" it. + const qreal inLen = qSqrt(node.inHandle->x() * node.inHandle->x() + node.inHandle->y() * node.inHandle->y()); + const qreal outLen = qSqrt(node.outHandle->x() * node.outHandle->x() + node.outHandle->y() * node.outHandle->y()); + const qreal avg = (inLen + outLen) / 2.0; + if (inLen > 1e-6) node.inHandle = *node.inHandle * (avg / inLen); + if (outLen > 1e-6) node.outHandle = *node.outHandle * (avg / outLen); + } + } + + const QDomElement after = snapshotXml(); + if (diagram()) + { + auto *undo = new PromoteShapeCommand(this, before, after); + undo->setText(tr("Modifier le type d'un nœud")); + diagram()->undoStack().push(undo); + } + + m_handleMode = HandleMode::NodeEdit; + rebuildHandles(); +} + +/** + @brief QetShapeItem::lockAspectRatio + Shift-constrained resize: keep the pre-drag width:height ratio, + driven by whichever dimension moved proportionally more, re-anchored + the same way the unconstrained result already was. +*/ +QRectF QetShapeItem::lockAspectRatio(const QRectF &oldRect, QRectF newRect, int resizeIndex, bool mirrored) +{ + Q_UNUSED(resizeIndex) + if (qFuzzyIsNull(oldRect.width()) || qFuzzyIsNull(oldRect.height())) + return newRect; + + const qreal ratio = oldRect.width() / oldRect.height(); + qreal w = newRect.width(), h = newRect.height(); + const qreal wChange = qAbs(w / oldRect.width() - 1.0); + const qreal hChange = qAbs(h / oldRect.height() - 1.0); + if (wChange > hChange) h = w / ratio; else w = h * ratio; + + const QPointF anchor = mirrored ? oldRect.center() : newRect.topLeft(); + if (mirrored) + return QRectF(anchor.x() - w / 2.0, anchor.y() - h / 2.0, w, h); + + // Re-anchor on whichever corner of newRect did not move -- simplest + // robust way to express "keep the fixed corner fixed" without having + // to re-derive which corner that is from resizeIndex. + const bool keepLeft = qFuzzyCompare(newRect.left(), oldRect.left()) || newRect.left() >= oldRect.right(); + const bool keepTop = qFuzzyCompare(newRect.top(), oldRect.top()) || newRect.top() >= oldRect.bottom(); + const qreal x = keepLeft ? newRect.left() : newRect.right() - w; + const qreal y = keepTop ? newRect.top() : newRect.bottom() - h; + return QRectF(x, y, w, h); +} + +bool QetShapeItem::isResizeCornerSlot(int slot) +{ + // The 4 corner indices among pointsForRect's 8-point (corner+edge) + // ordering -- shared by dragResize() (decides whether Alt detaches + // this vertex) and handleRoleTooltip() (decides whether to mention + // that in the tooltip), so the two can't drift apart. + return slot == 0 || slot == 2 || slot == 5 || slot == 7; +} + +void QetShapeItem::dragResize(int index, const QPointF &localPos, Qt::KeyboardModifiers mods) +{ + if (m_shapeType == Line) + { + prepareGeometryChange(); + (index == 0 ? m_P1 : m_P2) = localPos; + repositionHandles(); + emit geometryChanged(); + return; + } + + // Alt on a corner detaches that single vertex instead of resizing -- + // only corners (0,2,5,7 in pointsForRect's own ordering) carry that + // meaning; dragging an edge midpoint with Alt has no special effect. + if ((mods & Qt::AltModifier) && isResizeCornerSlot(index)) + { + promoteRectangleOrEllipseToPolygon(index, localPos); + return; + } + + const bool mirrored = mods & Qt::ControlModifier; + QRectF newRect = mirrored + ? QetGraphicsHandlerUtility::mirrorRectForPosAtIndex(localRect(), localPos, index) + : QetGraphicsHandlerUtility::rectForPosAtIndex(localRect(), localPos, index); + + if (mods & Qt::ShiftModifier) + newRect = lockAspectRatio(localRect(), newRect, index, mirrored); + + setRect(newRect.normalized()); +} + +void QetShapeItem::dragRotateHandle(int cornerIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods) +{ + // Both angles below are computed *without* ever un-rotating by the + // shape's current rotation: scenePivot is exactly pos()+pivot + // regardless of rotation/skew/scale (a linear map always fixes its + // own origin -- see shapetransform.h), and scaleAndShearOffset() is + // the corner's position with everything *except* rotation already + // applied. Solving "angleMouse == angleReference + rotation" this way + // is a plain assignment, not a recurrence -- unlike computing the + // angle via mapFromScene(), which divides out the *current* rotation + // and makes each frame's result depend on the previous frame's + // result: with the mouse held perfectly still that recurrence + // alternates between two values forever instead of settling, which is + // exactly the "jumps back and forth, lags behind" symptom. + const QPointF scenePivot = pos() + m_transform.pivot; + const qreal angleMouse = qRadiansToDegrees(qAtan2(scenePos.y() - scenePivot.y(), scenePos.x() - scenePivot.x())); + + const QPointF reference = scaleAndShearOffset(rotateHandleReference(cornerIndex)); + const qreal angleReference = qRadiansToDegrees(qAtan2(reference.y(), reference.x())); + + qreal angle = angleMouse - angleReference; + if (mods & Qt::ShiftModifier) + angle = qRound(angle / 15.0) * 15.0; + setRotation(angle); +} + +void QetShapeItem::dragSkewHandle(int edgeIndex, const QPointF &scenePos, Qt::KeyboardModifiers mods) +{ + // Solved in closed form for the one skew component being dragged, + // holding rotation, scale and the *other* skew axis at their current + // values -- the same reasoning as dragRotateHandle() above applies + // here: computing this via mapFromScene() would divide out the skew + // value this very function is trying to determine. + // + // Q = the edge midpoint's offset from pivot, after scale only (fixed + // during this drag). + // M = the target offset (mouse position, relative to pos()+pivot), + // with rotation undone (fixed rotation during this drag). + // Shear(kx,ky) maps Q to (Qx + kx*Qy, Qy + ky*Qx) -- see + // shapetransform.cpp's decomposeLinear() derivation for this same + // convention -- so each unknown drops out with a single division. + const QPointF pivot = m_transform.pivot; + const QPointF Q = scaleOnlyOffset(edgeMidpoint(localRect(), edgeIndex)); + + const qreal rad = qDegreesToRadians(m_transform.rotation); + const qreal c = qCos(rad), s = qSin(rad); + const QPointF targetRel = scenePos - pos() - pivot; + const QPointF M(targetRel.x() * c + targetRel.y() * s, + -targetRel.x() * s + targetRel.y() * c); + + qreal degrees; + if (edgeIndex == 0 || edgeIndex == 2) // N/S edge -> skewX ("sh"): M.x = Q.x + kx*Q.y + { + if (qFuzzyIsNull(Q.y())) return; + degrees = qRadiansToDegrees(qAtan((M.x() - Q.x()) / Q.y())); + if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0; + setSkewX(degrees); + } + else // E/W edge -> skewY ("sv"): M.y = Q.y + ky*Q.x + { + if (qFuzzyIsNull(Q.x())) return; + degrees = qRadiansToDegrees(qAtan((M.y() - Q.y()) / Q.x())); + if (mods & Qt::ShiftModifier) degrees = qRound(degrees / 15.0) * 15.0; + setSkewY(degrees); + } +} + +void QetShapeItem::dragPivotHandle(const QPointF &localPos) +{ + // Snapping the pivot to the shape's own corners/center/edge-midpoints + // (Shift) is a straightforward nearest-of-9-candidates check; left as + // plain movement for now so the handle math above stays the focus. + m_pivotIsCustom = true; + setPivot(localPos); +} + +void QetShapeItem::dragArcEndpoint(int which, const QPointF &localPos, Qt::KeyboardModifiers mods) +{ + const QRectF r = localRect(); + const QPointF center = r.center(); + const qreal rawAngle = -qRadiansToDegrees(qAtan2(localPos.y() - center.y(), localPos.x() - center.x())); + + // atan2's principal value jumps by 360 degrees at the +-180 degree + // crossing even though the mouse only moved a hair -- unwrap it + // relative to this endpoint's own previous value (the smallest + // equivalent delta) so the stored angle, and therefore the rendered + // arc, changes continuously through that crossing instead of + // flipping to its complementary half. + const qreal previous = (which == 0) ? m_startAngle : m_endAngle; + qreal delta = std::fmod(rawAngle - previous + 540.0, 360.0) - 180.0; + qreal angle = previous + delta; + + if (mods & Qt::ShiftModifier) + angle = qRound(angle / 15.0) * 15.0; + which == 0 ? setStartAngle(angle) : setEndAngle(angle); +} + +void QetShapeItem::dragCornerRadius(int which, const QPointF &localPos) +{ + const qreal radius = QetGraphicsHandlerUtility::radiusForPosAtIndex(localRect(), localPos, which); + if (m_modifie_radius_equaly) { setXRadius(radius); setYRadius(radius); } + else if (which == 0) setXRadius(radius); + else setYRadius(radius); +} + +/** + @brief QetShapeItem::dragPathAnchor + Normally just moves the anchor (Polygon vertex, or a Path node's + anchor -- its in/out handles are relative offsets, so they follow + for free). Alt+drag on a Path anchor, in NodeEdit mode, does + something different instead: pulls a fresh pair of symmetric handles + directly out of that node, letting a bare Corner node be reshaped + into a curve without needing the context menu's "make smooth" + action first -- the anchor itself stays fixed; the drag distance and + direction become the outgoing handle, mirrored exactly for the + incoming one, matching Illustrator's own "Alt+drag an anchor" + convention for this exact gesture. Deliberately scoped to NodeEdit + mode: the curve *would* still bend if allowed in Size mode too (the + underlying node data doesn't care what mode is active), but the new + handles themselves would be invisible until switching modes anyway, + which would just be confusing. +*/ +void QetShapeItem::dragPathAnchor(int which, const QPointF &localPos, Qt::KeyboardModifiers mods) +{ + prepareGeometryChange(); + if (m_shapeType == Polygon) + { + m_polygon.replace(which, localPos); + } + else if (which < m_nodes.size()) + { + PathNode &node = m_nodes[which]; + if ((mods & Qt::AltModifier) && m_handleMode == HandleMode::NodeEdit) + { + const QPointF offset = localPos - node.anchor; + node.outHandle = offset; + node.inHandle = -offset; + node.kind = NodeKind::Symmetric; + } + else + { + node.anchor = localPos; // in/out handles are relative offsets: they follow for free + } + } + repositionHandles(); + emit geometryChanged(); +} + +/** + @brief QetShapeItem::dragPathControlHandle + Dragging a Bezier control handle. Corner nodes have no linked + opposite handle to update. Smooth nodes keep the two handles + collinear through the anchor but let each keep its own length + (tangent-continuous, magnitude-independent). Symmetric nodes mirror + both direction and length exactly. Alt always means "break a normally + -linked relationship" in this design -- here, detaching this handle + from its mirror, permanently downgrading the node to Corner, exactly + the same convention Alt already has on a rectangle's resize corner + (see dragResize()). +*/ +void QetShapeItem::dragPathControlHandle(bool isOutHandle, int nodeIndex, const QPointF &localPos, Qt::KeyboardModifiers mods) +{ + if (nodeIndex >= m_nodes.size()) + return; + + prepareGeometryChange(); + PathNode &node = m_nodes[nodeIndex]; + const QPointF newOffset = localPos - node.anchor; + auto &dragged = isOutHandle ? node.outHandle : node.inHandle; + dragged = newOffset; + + if (mods & Qt::AltModifier) + node.kind = NodeKind::Corner; + else + mirrorOppositeHandle(node, isOutHandle); + + repositionHandles(); +} + +/** + @brief QetShapeItem::mirrorOppositeHandle + Given a node whose one handle (out, if justChangedIsOut; in, + otherwise) was just set directly, updates its *other* handle to + respect the node's kind -- Smooth keeps both collinear through the + anchor but lets each keep its own prior length (tangent-continuous, + magnitude-independent); Symmetric also equalizes the lengths; Corner + does nothing, since it has no linked handle to update. If the other + handle doesn't exist yet at all, it's created here rather than left + missing -- matching its own length to whichever handle was just + dragged, the only sensible default when there's no prior length of + its own to preserve. Shared by dragPathControlHandle() (a handle + dragged directly), dragCurveSegment() (both handles moved together, + indirectly, by dragging the curve between two nodes), and + dragPathAnchor()'s Alt-drag (pulling a fresh pair of handles out of + a bare Corner node). +*/ +void QetShapeItem::mirrorOppositeHandle(PathNode &node, bool justChangedIsOut) +{ + if (node.kind == NodeKind::Corner) + return; + + auto &changed = justChangedIsOut ? node.outHandle : node.inHandle; + auto &mirrored = justChangedIsOut ? node.inHandle : node.outHandle; + if (!changed) + return; + + const qreal len = qSqrt(changed->x() * changed->x() + changed->y() * changed->y()); + if (len < 1e-6) + return; + + const QPointF direction(-changed->x() / len, -changed->y() / len); + const qreal keptLength = (node.kind == NodeKind::Symmetric || !mirrored) + ? len + : qSqrt(mirrored->x() * mirrored->x() + mirrored->y() * mirrored->y()); + mirrored = direction * keptLength; +} + +/** + @brief QetShapeItem::dragCurveSegment + Inkscape-style "grab the curve itself, not a handle" reshaping: moves + both of the segment's control points by the same amount, scaled so + the curve ends up passing through localPos at the parameter t where + the drag started. Verified algebraically and numerically before + shipping: for control points P1,P2 shifted by a constant delta, the + curve's own point at t shifts by exactly 3*(1-t)*t*delta, which is + exactly the factor divided back out below -- so the new curve passes + through localPos exactly, not approximately. + Always measured against the *original* control points captured when + the drag started (m_curveDragOriginalP1/P2), not the current + (possibly already-adjusted, this same drag) ones -- otherwise each + frame's adjustment would compound on top of the last, sending the + curve shooting off far past the cursor instead of tracking it. + Near either endpoint (t within 5% of 0 or 1) the curve is barely + sensitive to its control points at all -- the same reason grabbing a + suspension bridge's deck right next to a pylon barely moves it -- so + those clicks are left alone rather than requiring huge, unpredictable + handle movements for a small visual change; they're also close enough + to an anchor that the user most likely meant to grab that instead. +*/ +void QetShapeItem::dragCurveSegment(int segmentIndex, qreal t, const QPointF &localPos) +{ + if (t < 0.05 || t > 0.95) + return; + + const int count = m_nodes.size(); + const int nextIndex = (segmentIndex + 1) % count; + PathNode &a = m_nodes[segmentIndex]; + PathNode &b = m_nodes[nextIndex]; + + const QPointF p0 = a.anchor; + const QPointF p3 = b.anchor; + const qreal u = 1 - t; + + const QPointF originalCurvePoint = + u*u*u*p0 + 3*u*u*t*m_curveDragOriginalP1 + 3*u*t*t*m_curveDragOriginalP2 + t*t*t*p3; + const QPointF desired = localPos - originalCurvePoint; + const qreal factor = 3 * u * t; // > 0 given the t range guarded above + const QPointF handleDelta = desired / factor; + + prepareGeometryChange(); + a.outHandle = (m_curveDragOriginalP1 - p0) + handleDelta; + b.inHandle = (m_curveDragOriginalP2 - p3) + handleDelta; + + mirrorOppositeHandle(a, true); + mirrorOppositeHandle(b, false); + + repositionHandles(); +} + +/** + @brief QetShapeItem::handlerMousePressEvent + @param handlerIndex +*/ +void QetShapeItem::handlerMousePressEvent(int handlerIndex) +{ + Q_UNUSED(handlerIndex) m_old_P1 = m_P1; m_old_P2 = m_P2; m_old_polygon = m_polygon; m_old_xRadius = m_xRadius; m_old_yRadius = m_yRadius; + m_old_transform = m_transform; + m_old_pos = pos(); + m_old_nodes = m_nodes; if(m_xRadius == 0 && m_yRadius == 0) { m_modifie_radius_equaly = true; } @@ -723,117 +2487,152 @@ void QetShapeItem::handlerMousePressEvent() /** @brief QetShapeItem::handlerMouseMoveEvent + @param handlerIndex @param event */ -void QetShapeItem::handlerMouseMoveEvent(QGraphicsSceneMouseEvent *event) +void QetShapeItem::handlerMouseMoveEvent(int handlerIndex, QGraphicsSceneMouseEvent *event) { - QPointF new_pos = event->scenePos(); - if (event->modifiers() != Qt::ControlModifier) - new_pos = Diagram::snapToGrid(event->scenePos()); - new_pos = mapFromScene(new_pos); + QPointF scenePos = event->scenePos(); + // Bitwise flag check, not exact equality -- see + // DiagramEventAddShape::mousePressEvent's identical fix and comment: + // modifiers() == Ctrl alone fails the moment any other key (Alt, for + // dragPathAnchor()'s handle-creation gesture) is also held, + // silently falling through to snapToGrid() even though Ctrl is held. + if (!(event->modifiers() & Qt::ControlModifier)) + scenePos = Diagram::snapToGrid(scenePos); - switch (m_shapeType) + const HandleRole role = m_handleRoles.value(handlerIndex, HandleRole::Resize); + const int slot = m_handleSlot.value(handlerIndex, 0); + const Qt::KeyboardModifiers mods = event->modifiers(); + + // Rotate and SkewEdge are deliberately handled in scene space -- see + // their comments for why mapFromScene() (used for every other role + // below) is exactly the wrong tool for them. + if (role == HandleRole::Rotate) { dragRotateHandle(slot, scenePos, mods); return; } + if (role == HandleRole::SkewEdge) { dragSkewHandle(slot, scenePos, mods); return; } + + const QPointF new_pos = mapFromScene(scenePos); + + switch (role) { - case Line: - prepareGeometryChange(); - m_vector_index == 0 ? m_P1 = new_pos : m_P2 = new_pos; - adjustHandlerPos(); - break; - - case Rectangle: - if (m_resize_mode == 1) { - setRect(QetGraphicsHandlerUtility::rectForPosAtIndex(QRectF(m_P1, m_P2), new_pos, m_vector_index)); - break; - } - else if (m_resize_mode == 2) { - setRect(QetGraphicsHandlerUtility::mirrorRectForPosAtIndex(QRectF(m_P1, m_P2), new_pos, m_vector_index)); - break; - } - else { - qreal radius = QetGraphicsHandlerUtility::radiusForPosAtIndex(QRectF(m_P1, m_P2), new_pos, m_vector_index); - if(m_modifie_radius_equaly) { - setXRadius(radius); - setYRadius(radius); - } - else if(m_vector_index == 0) { - setXRadius(radius); - } - else { - setYRadius(radius); - } - adjustHandlerPos(); - break; - } - case Ellipse: - if (m_resize_mode == 1) { - setRect(QetGraphicsHandlerUtility::rectForPosAtIndex(QRectF(m_P1, m_P2), new_pos, m_vector_index)); - break; - } - else { - setRect(QetGraphicsHandlerUtility::mirrorRectForPosAtIndex(QRectF(m_P1, m_P2), new_pos, m_vector_index)); - break; - } - - case Polygon: - prepareGeometryChange(); - m_polygon.replace(m_vector_index, new_pos); - adjustHandlerPos(); - break; - } //End switch + case HandleRole::Resize: dragResize(slot, new_pos, mods); break; + case HandleRole::Pivot: dragPivotHandle(new_pos); break; + case HandleRole::CornerRadius: dragCornerRadius(slot, new_pos); break; + case HandleRole::ArcEndpoint: dragArcEndpoint(slot, new_pos, mods); break; + case HandleRole::PathAnchor: dragPathAnchor(slot, new_pos, mods); break; + case HandleRole::PathControlIn: dragPathControlHandle(false, slot, new_pos, mods); break; + case HandleRole::PathControlOut: dragPathControlHandle(true, slot, new_pos, mods); break; + case HandleRole::Rotate: + case HandleRole::SkewEdge: + break; // handled above + } } /** @brief QetShapeItem::handlerMouseReleaseEvent - @param qghi - @param event + @param handlerIndex */ -void QetShapeItem::handlerMouseReleaseEvent() +void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex) { m_modifie_radius_equaly = false; + const HandleRole role = m_handleRoles.value(handlerIndex, HandleRole::Resize); + const int slot = m_handleSlot.value(handlerIndex, 0); - if (diagram()) + if (!diagram()) + return; + + QUndoCommand *undo = nullptr; + + switch (role) { - QPropertyUndoCommand *undo = nullptr; - if ((m_shapeType & (Line | Rectangle | Ellipse)) && ((m_P1 != m_old_P1 || m_P2 != m_old_P2) || - (m_old_xRadius != XRadius() || m_old_yRadius != m_yRadius)) - ) - { - switch(m_shapeType) + case HandleRole::Resize: + if (m_shapeType == Line) { - case Line: { - undo = new QPropertyUndoCommand(this, "line",QLineF(m_old_P1, m_old_P2), QLineF(m_P1, m_P2)); - break; - } - case Rectangle: { - if (m_resize_mode == 1 || m_resize_mode == 2) { - undo = new QPropertyUndoCommand(this, "rect",QRectF(m_old_P1, m_old_P2), QRectF(m_P1, m_P2).normalized()); - } - else if (m_resize_mode == 3) - { - undo = new QPropertyUndoCommand(this, "xRadius", m_old_xRadius, m_xRadius); - QPropertyUndoCommand *undo_ = new QPropertyUndoCommand(this, "yRadius", m_old_yRadius, m_yRadius, undo); - undo_->setAnimated(); - } - break; - } - case Ellipse: { - undo = new QPropertyUndoCommand(this, "rect",QRectF(m_old_P1, m_old_P2), QRectF(m_P1, m_P2).normalized()); - break; - } - case Polygon: break; + if (m_P1 != m_old_P1 || m_P2 != m_old_P2) + undo = new QPropertyUndoCommand(this, "line", QLineF(m_old_P1, m_old_P2), QLineF(m_P1, m_P2)); } - if (undo) { - undo->setAnimated(true, false); + else if (m_P1 != m_old_P1 || m_P2 != m_old_P2) + { + undo = new QPropertyUndoCommand(this, "rect", QRectF(m_old_P1, m_old_P2), QRectF(m_P1, m_P2).normalized()); } - } - else if (m_shapeType == Polygon && (m_polygon != m_old_polygon)) - undo = new QPropertyUndoCommand(this, "polygon", m_old_polygon, m_polygon); + break; - if(undo) - { + case HandleRole::Rotate: + if (!qFuzzyCompare(m_transform.rotation, m_old_transform.rotation)) + undo = new QPropertyUndoCommand(this, "rotation", m_old_transform.rotation, m_transform.rotation); + break; + + case HandleRole::SkewEdge: + if (!qFuzzyCompare(m_transform.skewX, m_old_transform.skewX)) + undo = new QPropertyUndoCommand(this, "skewX", m_old_transform.skewX, m_transform.skewX); + else if (!qFuzzyCompare(m_transform.skewY, m_old_transform.skewY)) + undo = new QPropertyUndoCommand(this, "skewY", m_old_transform.skewY, m_transform.skewY); + break; + + case HandleRole::Pivot: + if (m_transform.pivot != m_old_transform.pivot) + { + undo = new QUndoCommand(tr("Deplacer le centre de rotation")); + new QPropertyUndoCommand(this, "pos", m_old_pos, pos(), undo); + new QPropertyUndoCommand(this, "pivot", m_old_transform.pivot, m_transform.pivot, undo); + } + break; + + case HandleRole::CornerRadius: + if (m_old_xRadius != m_xRadius || m_old_yRadius != m_yRadius) + { + undo = new QPropertyUndoCommand(this, "xRadius", m_old_xRadius, m_xRadius); + new QPropertyUndoCommand(this, "yRadius", m_old_yRadius, m_yRadius, undo); + } + break; + + case HandleRole::ArcEndpoint: + // startAngle/endAngle changes are cosmetic-cost enough (and + // re-derived from each other on snap-to-full-ellipse) that + // they are intentionally not wrapped in undo here yet -- flag + // for a follow-up once ArcEndpoint dragging ships in the UI. + break; + + case HandleRole::PathAnchor: + if (m_shapeType == Polygon && m_polygon != m_old_polygon) + { + undo = new QPropertyUndoCommand(this, "polygon", m_old_polygon, m_polygon); + } + else if (m_shapeType == Path && m_nodes != m_old_nodes) + { + // PathNode/QVector isn't a Q_PROPERTY-friendly + // type, so this reuses PromoteShapeCommand's generic + // before/after XML snapshot mechanism instead of a + // dedicated undo class -- swap in the old nodes just + // long enough to snapshot them, then restore. + const QVector after = m_nodes; + m_nodes = m_old_nodes; + const QDomElement before = snapshotXml(); + m_nodes = after; + const QDomElement afterXml = snapshotXml(); + undo = new PromoteShapeCommand(this, before, afterXml); + } + break; + + case HandleRole::PathControlIn: + case HandleRole::PathControlOut: + if (m_nodes != m_old_nodes) + { + const QVector after = m_nodes; + m_nodes = m_old_nodes; + const QDomElement before = snapshotXml(); + m_nodes = after; + const QDomElement afterXml = snapshotXml(); + undo = new PromoteShapeCommand(this, before, afterXml); + } + break; + } + + if (undo) + { + if (undo->text().isEmpty()) undo->setText(tr("Modifier %1").arg(name())); - diagram()->undoStack().push(undo); - } + diagram()->undoStack().push(undo); } } @@ -847,6 +2646,14 @@ bool QetShapeItem::fromXml(const QDomElement &e) { if (e.tagName() != "shape") return (false); + // fromXml() is also used to *restore* an already-displayed item's + // state (PromoteShapeCommand's undo/redo), not just to populate a + // freshly constructed one -- without this, Qt has no way to know the + // item's *previous* on-screen bounding rect needs repainting once the + // geometry underneath it changes, so the old rendering stays stuck + // until something unrelated forces a repaint of that area. + prepareGeometryChange(); + is_movable_ = (e.attribute("is_movable").toInt()); m_closed = e.attribute("closed", "0").toInt(); m_pen = QETXML::penFromXml(e.firstChildElement("pen")); @@ -856,7 +2663,7 @@ bool QetShapeItem::fromXml(const QDomElement &e) QMetaEnum me = metaObject()->enumerator(metaObject()->indexOfEnumerator("ShapeType")); m_shapeType = QetShapeItem::ShapeType(me.keysToValue(type.toStdString().data())); - if (m_shapeType != Polygon) + if (m_shapeType != Polygon && m_shapeType != Path) { m_P1.setX(e.attribute("x1", nullptr).toDouble()); m_P1.setY(e.attribute("y1", nullptr).toDouble()); @@ -869,13 +2676,89 @@ bool QetShapeItem::fromXml(const QDomElement &e) setYRadius(e.attribute("ry", "0").toDouble()); } } - else { + if (m_shapeType == Polygon) + { + // fromXml() must be safe to call on an already-populated item, not + // just a freshly constructed one: PromoteShapeCommand's undo/redo + // (and the automatic redo() that QUndoStack::push() performs the + // instant a command is pushed) both call it to *restore* a prior + // state, on an object that already has geometry in it. Appending + // onto whatever is already there -- rather than replacing it -- + // silently duplicated every point on the very first undo-worthy + // edit. + m_polygon.clear(); for(const QDomElement& de : QET::findInDomElement(e, "points", "point")) { m_polygon << QPointF(de.attribute("x", nullptr).toDouble(), de.attribute("y", nullptr).toDouble()); } } + else if (m_shapeType == Path) + { + m_nodes.clear(); + for (const QDomElement &nodeElement : QET::findInDomElement(e, "nodes", "node")) + { + PathNode node; + node.anchor = QPointF(nodeElement.attribute("x").toDouble(), nodeElement.attribute("y").toDouble()); + const QString kind = nodeElement.attribute("kind", "corner"); + node.kind = (kind == "smooth") ? NodeKind::Smooth + : (kind == "symmetric") ? NodeKind::Symmetric + : NodeKind::Corner; + QDomElement in = nodeElement.firstChildElement("in"); + if (!in.isNull()) + node.inHandle = QPointF(in.attribute("dx").toDouble(), in.attribute("dy").toDouble()); + QDomElement out = nodeElement.firstChildElement("out"); + if (!out.isNull()) + node.outHandle = QPointF(out.attribute("dx").toDouble(), out.attribute("dy").toDouble()); + m_nodes << node; + } + } + + QDomElement transformElement = e.firstChildElement("transform"); + if (!transformElement.isNull()) + { + m_transform.rotation = transformElement.attribute("rotation", "0").toDouble(); + m_transform.skewX = transformElement.attribute("skewX", "0").toDouble(); + m_transform.skewY = transformElement.attribute("skewY", "0").toDouble(); + m_transform.scaleX = transformElement.attribute("scaleX", "1").toDouble(); + m_transform.scaleY = transformElement.attribute("scaleY", "1").toDouble(); + m_transform.pivot = QPointF(transformElement.attribute("pivotX", "0").toDouble(), + transformElement.attribute("pivotY", "0").toDouble()); + m_pivotIsCustom = true; + } + else + { + m_transform = ShapeTransform(); + m_transform.pivot = localRect().center(); + m_pivotIsCustom = false; + } + setTransform(m_transform.toMatrix()); + + QDomElement arcElement = e.firstChildElement("arc"); + if (!arcElement.isNull() && m_shapeType == Ellipse) + { + m_startAngle = arcElement.attribute("startAngle", "0").toDouble(); + m_endAngle = m_startAngle + arcElement.attribute("spanAngle", "360").toDouble(); + const QString closure = arcElement.attribute("closure", "none"); + m_arcClosure = (closure == "chord") ? Chord : (closure == "pie") ? Pie : NoClosure; + } + + if (e.hasAttribute("posX") || e.hasAttribute("posY")) + { + QGraphicsItem::setPos(e.attribute("posX", "0").toDouble(), + e.attribute("posY", "0").toDouble()); + } + setZValue(e.attribute("z", QString::number(this->zValue())).toDouble()); + // fromXml() can change anything about the shape -- geometry, node + // count, even shapeType() itself (undoing a Rectangle->Polygon + // promotion) -- so a full rebuild, not just a reposition, is the only + // choice that's guaranteed consistent with whatever state was just + // restored. Only when selected: an unselected item should have no + // handles at all, and this can run on any item in the diagram, not + // just the one currently being interacted with. + if (isSelected()) + rebuildHandles(); + return (true); } @@ -897,12 +2780,12 @@ QDomElement QetShapeItem::toXml(QDomDocument &document) const result.setAttribute("is_movable", bool(is_movable_)); result.setAttribute("closed", bool(m_closed)); - if (m_shapeType != Polygon) + if (m_shapeType != Polygon && m_shapeType != Path) { - result.setAttribute("x1", QString::number(mapToScene(m_P1).x())); - result.setAttribute("y1", QString::number(mapToScene(m_P1).y())); - result.setAttribute("x2", QString::number(mapToScene(m_P2).x())); - result.setAttribute("y2", QString::number(mapToScene(m_P2).y())); + result.setAttribute("x1", QString::number(m_P1.x())); + result.setAttribute("y1", QString::number(m_P1.y())); + result.setAttribute("x2", QString::number(m_P2.x())); + result.setAttribute("y2", QString::number(m_P2.y())); if (m_shapeType == Rectangle) { @@ -910,19 +2793,86 @@ QDomElement QetShapeItem::toXml(QDomDocument &document) const result.setAttribute("ry", QString::number(m_yRadius)); } } - else + if (m_shapeType == Polygon) { QDomElement points = document.createElement("points"); for (QPointF p : m_polygon) { QDomElement point = document.createElement("point"); - QPointF pf = mapToScene(p); - point.setAttribute("x", QString::number(pf.x())); - point.setAttribute("y", QString::number(pf.y())); + point.setAttribute("x", QString::number(p.x())); + point.setAttribute("y", QString::number(p.y())); points.appendChild(point); } result.appendChild(points); } + else if (m_shapeType == Path) + { + QDomElement nodes = document.createElement("nodes"); + for (const PathNode &n : m_nodes) + { + QDomElement node = document.createElement("node"); + node.setAttribute("x", QString::number(n.anchor.x())); + node.setAttribute("y", QString::number(n.anchor.y())); + if (n.kind != NodeKind::Corner) + node.setAttribute("kind", n.kind == NodeKind::Smooth ? "smooth" : "symmetric"); + if (n.inHandle) + { + QDomElement in = document.createElement("in"); + in.setAttribute("dx", QString::number(n.inHandle->x())); + in.setAttribute("dy", QString::number(n.inHandle->y())); + node.appendChild(in); + } + if (n.outHandle) + { + QDomElement out = document.createElement("out"); + out.setAttribute("dx", QString::number(n.outHandle->x())); + out.setAttribute("dy", QString::number(n.outHandle->y())); + node.appendChild(out); + } + nodes.appendChild(node); + } + result.appendChild(nodes); + } + + // Omitted entirely at identity, exactly like an absent radius today -- + // this is what keeps old files, and files that never touch rotation, + // byte-for-byte unchanged. + if (!m_transform.isIdentity() || m_pivotIsCustom) + { + QDomElement transformElement = document.createElement("transform"); + transformElement.setAttribute("rotation", QString::number(m_transform.rotation)); + transformElement.setAttribute("skewX", QString::number(m_transform.skewX)); + transformElement.setAttribute("skewY", QString::number(m_transform.skewY)); + transformElement.setAttribute("scaleX", QString::number(m_transform.scaleX)); + transformElement.setAttribute("scaleY", QString::number(m_transform.scaleY)); + transformElement.setAttribute("pivotX", QString::number(m_transform.pivot.x())); + transformElement.setAttribute("pivotY", QString::number(m_transform.pivot.y())); + result.appendChild(transformElement); + } + + if (m_shapeType == Ellipse && (!isFullEllipse() || m_arcClosure != NoClosure)) + { + QDomElement arcElement = document.createElement("arc"); + arcElement.setAttribute("startAngle", QString::number(m_startAngle)); + arcElement.setAttribute("spanAngle", QString::number(spanAngle())); + if (m_arcClosure != NoClosure) + arcElement.setAttribute("closure", m_arcClosure == Chord ? "chord" : "pie"); + result.appendChild(arcElement); + } + + // pos() is meaningful now that pivot moves (and, transparently, every + // ordinary resize via the auto-recentring pivot -- see setRect()) + // compensate through it rather than through m_P1/m_P2. It was never + // written here before; omitted at every reload it silently reset to + // (0,0), which is exactly the "position shifts slightly on reload" + // bug -- geometry and transform round-tripped correctly, but the + // placement component of the two together did not. + if (!pos().isNull()) + { + result.setAttribute("posX", QString::number(pos().x())); + result.setAttribute("posY", QString::number(pos().y())); + } + result.setAttribute("z", QString::number(this->zValue())); return(result); @@ -937,6 +2887,21 @@ QDomElement QetShapeItem::toXml(QDomDocument &document) const */ bool QetShapeItem::toDXF(const QString &filepath,const QPen &pen) { + // A non-identity transform means the shape is no longer axis-aligned + // in scene space: mapping just two opposite corners and building a + // new axis-aligned QRectF from them (the old Rectangle/Ellipse path + // below) would silently produce the wrong quadrilateral. Fall back to + // exporting the mapped outline as a polygon/polyline instead, exactly + // the path already used for Polygon today. + if (!m_transform.isIdentity() && (m_shapeType == Rectangle || m_shapeType == Ellipse)) + { + const QPolygonF mappedOutline = mapToScene(outline().toFillPolygon()); + if (m_shapeType == Rectangle || isFullEllipse()) + Createdxf::drawPolygon(filepath, mappedOutline, Createdxf::dxfColor(pen)); + else + Createdxf::drawPolyline(filepath, mappedOutline, Createdxf::dxfColor(pen)); + return true; + } switch (m_shapeType) { @@ -959,11 +2924,34 @@ bool QetShapeItem::toDXF(const QString &filepath,const QPen &pen) Createdxf::dxfColor(pen)); return true; case Polygon: - if(m_polygon.isClosed()) + // m_closed, not m_polygon.isClosed(): m_polygon's own points + // never include a duplicate closing point in this design -- + // outline() relies on m_closed + QPainterPath::closeSubpath() + // for the visual effect, so isClosed()'s geometric check + // (do the first and last points happen to coincide?) almost + // always reads false regardless of the user's actual intent. + if (m_closed) Createdxf::drawPolygon(filepath,m_polygon,Createdxf::dxfColor(pen)); else Createdxf::drawPolyline(filepath,m_polygon,Createdxf::dxfColor(pen)); return true; + case Path: + { + // toSubpathPolygons(), not outline().toFillPolygon(): the + // latter exists for fill-rendering, which inherently needs a + // closed shape, so it silently appends a closing point onto + // *any* path regardless of m_closed -- confirmed directly + // against the real QPainterPath before relying on it here. + // toSubpathPolygons() has no such fill-oriented bias and + // correctly preserves the open/closed distinction. + const QList subpaths = outline().toSubpathPolygons(); + const QPolygonF flattened = subpaths.isEmpty() ? QPolygonF() : mapToScene(subpaths.first()); + if (m_closed) + Createdxf::drawPolygon(filepath, flattened, Createdxf::dxfColor(pen)); + else + Createdxf::drawPolyline(filepath, flattened, Createdxf::dxfColor(pen)); + return true; + } default: return false; } @@ -990,8 +2978,9 @@ QString QetShapeItem::name() const switch (m_shapeType) { case Line: return tr("une ligne"); case Rectangle: return tr("un rectangle"); - case Ellipse: return tr("une éllipse"); + case Ellipse: return isFullEllipse() ? tr("une éllipse") : tr("un arc"); case Polygon: return tr("une polyligne"); + case Path: return tr("une courbe"); default: return tr("une shape"); } } diff --git a/sources/qetgraphicsitem/qetshapeitem.h b/sources/qetgraphicsitem/qetshapeitem.h index d13f71475..fdb7fe6a4 100644 --- a/sources/qetgraphicsitem/qetshapeitem.h +++ b/sources/qetgraphicsitem/qetshapeitem.h @@ -20,8 +20,11 @@ #include "../QetGraphicsItemModeler/qetgraphicshandleritem.h" #include "qetgraphicsitem.h" +#include "shapetransform.h" #include +#include +#include class QDomElement; class QDomDocument; @@ -30,8 +33,10 @@ class QAction; /** @brief The QetShapeItem class - this class is used to draw a basic shape (line, rectangle, ellipse) - into a diagram, that can be saved to .qet file. + this class is used to draw a basic shape (line, rectangle, ellipse, + polygon or free-form path) into a diagram, that can be saved to a + .qet file. Beyond its local geometry, a shape may also carry a + rotation/skew/scale (see ShapeTransform) around an arbitrary pivot. */ class QetShapeItem : public QetGraphicsItem { @@ -46,21 +51,86 @@ class QetShapeItem : public QetGraphicsItem Q_PROPERTY(qreal xRadius READ XRadius WRITE setXRadius NOTIFY XRadiusChanged) Q_PROPERTY(qreal yRadius READ YRadius WRITE setYRadius NOTIFY YRadiusChanged) + // One property per ShapeTransform scalar -- this is what lets a plain + // QPropertyUndoCommand(this, "rotation", oldValue, newValue) work for + // every handle, exactly like xRadius/yRadius already do for corner + // rounding. + Q_PROPERTY(qreal rotation READ rotation WRITE setRotation NOTIFY transformChanged) + Q_PROPERTY(qreal skewX READ skewX WRITE setSkewX NOTIFY transformChanged) + Q_PROPERTY(qreal skewY READ skewY WRITE setSkewY NOTIFY transformChanged) + Q_PROPERTY(qreal scaleFactorX READ scaleFactorX WRITE setScaleFactorX NOTIFY transformChanged) + Q_PROPERTY(qreal scaleFactorY READ scaleFactorY WRITE setScaleFactorY NOTIFY transformChanged) + Q_PROPERTY(QPointF pivot READ pivot WRITE setPivot NOTIFY transformChanged) + + Q_PROPERTY(qreal startAngle READ startAngle WRITE setStartAngle NOTIFY arcChanged) + Q_PROPERTY(qreal endAngle READ endAngle WRITE setEndAngle NOTIFY arcChanged) + signals: void penChanged(); void brushChanged(); void closeChanged(); void XRadiusChanged(); void YRadiusChanged(); - - + void transformChanged(); + void arcChanged(); + void geometryChanged(); // P1/P2, polygon points, or path nodes changed -- lets the properties panel stay in sync while a handle is dragged, not just when it's typed into + public: enum ShapeType {Line =1, Rectangle =2, Ellipse =4, - Polygon =8 }; + Polygon =8, + Path =16 }; Q_ENUM (ShapeType) + enum ArcClosure {NoClosure = 0, Chord = 1, Pie = 2}; + Q_ENUM (ArcClosure) + + // Point of a Path shape. Anchors are in the same local coordinate + // frame as an ordinary Polygon's points; handle offsets are stored + // *relative to the anchor*, so moving a node never has to rewrite + // its own handle coordinates. + enum class NodeKind {Corner, Smooth, Symmetric}; + struct PathNode { + QPointF anchor; + NodeKind kind = NodeKind::Corner; + std::optional inHandle; + std::optional outHandle; + + bool operator==(const PathNode &other) const { + return anchor == other.anchor && kind == other.kind + && inHandle == other.inHandle && outHandle == other.outHandle; + } + }; + + // Orthogonal to ShapeType: which handle set is currently shown. + // Cycled by clicking an already-selected shape without dragging: + // Size -> Corner -> RotateSkew -> Size for Rectangle (the only + // type with a corner-radius concept); Size -> NodeEdit -> + // RotateSkew -> Size for Path (reveals control handles for + // every node that has any); Size -> RotateSkew -> Size for + // everything else. One unified click-cycle for every shape + // type, rather than a separate, less discoverable gesture + // (e.g. double-click) for any one shape's extra mode. + enum class HandleMode {Size, Corner, NodeEdit, RotateSkew}; + + // index conventions, deliberately matched to what already exists + // rather than invented fresh: + // Resize 0..7, same order as QetGraphicsHandlerUtility::pointsForRect + // (this is exactly today's Rectangle/Ellipse handle set -- + // only the Ctrl/Shift dispatch around it is new) + // Rotate 0..3, corners: NW, NE, SE, SW + // SkewEdge 0..3, edges: N, E, S, W + // CornerRadius 0..1, same order as QetGraphicsHandlerUtility::pointForRadiusRect + // ArcEndpoint 0 = start angle, 1 = end angle + enum class HandleRole { + Resize, // Size mode + Rotate, SkewEdge, Pivot, // RotateSkew mode + CornerRadius, // Rectangle, always shown alongside Size handles + ArcEndpoint, // Ellipse, always shown + PathAnchor, PathControlIn, PathControlOut // Polygon/Path, node-edit mode (see setPathNodes()) + }; + enum { Type = UserType + 1008 }; QetShapeItem( @@ -102,6 +172,41 @@ class QetShapeItem : public QetGraphicsItem qreal YRadius() const {return m_yRadius;} void setYRadius(qreal Y); + //Transform: one accessor pair per ShapeTransform scalar (see + //the Q_PROPERTY block above for why they are not grouped into + //a single property). + const ShapeTransform &shapeTransform() const {return m_transform;} + qreal rotation() const {return m_transform.rotation;} + void setRotation(qreal degrees); + qreal skewX() const {return m_transform.skewX;} + void setSkewX(qreal degrees); + qreal skewY() const {return m_transform.skewY;} + void setSkewY(qreal degrees); + qreal scaleFactorX() const {return m_transform.scaleX;} + void setScaleFactorX(qreal factor); + qreal scaleFactorY() const {return m_transform.scaleY;} + void setScaleFactorY(qreal factor); + QPointF pivot() const {return m_transform.pivot;} + void setPivot(const QPointF &pivot); // moves the pivot handle: compensates pos() so the shape does not jump + void resetPivotToBoundingRectCenter(); + void enableNodeEditMode(); // Path only: switches to NodeEdit mode, so every node's control handles become visible + + //Arc: only meaningful when shapeType() == Ellipse. A full + //ellipse is just an arc with span 360 -- there is no separate + //Arc shape type. + qreal startAngle() const {return m_startAngle;} + void setStartAngle(qreal degrees); + qreal endAngle() const {return m_endAngle;} + void setEndAngle(qreal degrees); + qreal spanAngle() const {return m_endAngle - m_startAngle;} + bool isFullEllipse() const {return qFuzzyCompare(qAbs(spanAngle()), qreal(360));} + ArcClosure arcClosure() const {return m_arcClosure;} + void setArcClosure(ArcClosure closure); + + //Path (Bezier): only meaningful when shapeType() == Path. + const QVector &pathNodes() const {return m_nodes;} + void setPathNodes(const QVector &nodes); + //Methods available for polygon shape int pointsCount () const; void setNextPoint (QPointF P); @@ -118,6 +223,8 @@ class QetShapeItem : public QetGraphicsItem void hoverEnterEvent (QGraphicsSceneHoverEvent *event) override; void hoverLeaveEvent (QGraphicsSceneHoverEvent *event) override; void mousePressEvent (QGraphicsSceneMouseEvent *event) override; + void mouseMoveEvent (QGraphicsSceneMouseEvent *event) override; + void mouseReleaseEvent (QGraphicsSceneMouseEvent *event) override; QVariant itemChange( GraphicsItemChange change, const QVariant &value) override; @@ -128,17 +235,63 @@ class QetShapeItem : public QetGraphicsItem QGraphicsSceneContextMenuEvent *event) override; private: - void switchResizeMode(); - void addHandler(); - void adjustHandlerPos(); + void toggleHandleMode(); + HandleMode nextHandleMode() const; // what a click would switch to from here -- shared by toggleHandleMode() and the tooltip + void updateModeHint(); // keeps the tooltip in sync with nextHandleMode() + void refreshInteractionHints(); // updateModeHint(), plus an immediate re-show of tooltip/status bar if currently hovered + void showStatusHint(const QString &text) const; + void clearStatusHint() const; + QString currentModeStatusHint() const; // richer, one-line gesture/modifier reference for the status bar, shown when hovering the shape body + QString handleRoleTooltip(HandleRole role, int slot) const; // shown natively by Qt when hovering that specific handle, and pushed to the status bar too + static QString handleModeLabel(HandleMode mode); + static bool isResizeCornerSlot(int slot); // true for the 4 corner slots (of 8) in QetGraphicsHandlerUtility::pointsForRect's ordering + void rebuildHandles(); // (re)creates handler items -- only when the *set* of handles changes + void repositionHandles(); // moves existing handler items -- safe to call every frame of a live drag void insertPoint(); void removePoint(); - - void handlerMousePressEvent(); - void handlerMouseMoveEvent(QGraphicsSceneMouseEvent *event); - void handlerMouseReleaseEvent(); + void convertToPathExplicitly(); // context-menu action; see promoteRectangleOrEllipseToPolygon() + void mirror(bool horizontal); // context-menu action: flips scaleFactorX (horizontal) or scaleFactorY (vertical) around the current pivot + void setNodeKind(int nodeIndex, NodeKind kind); // context-menu action on a Path node - ///ATTRIBUTES + void handlerMousePressEvent(int handlerIndex); + void handlerMouseMoveEvent(int handlerIndex, QGraphicsSceneMouseEvent *event); + void handlerMouseReleaseEvent(int handlerIndex); + + // One dispatch function per handle role -- called from + // handlerMouseMoveEvent() with the mouse position already + // mapped to local coordinates and any grid snap applied. + // dragResize() covers both the plain and Ctrl/Shift/Alt-modified + // interpretations of the whole Size handle set. + 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); + void dragPivotHandle (const QPointF &localPos); + void dragArcEndpoint (int which, const QPointF &localPos, Qt::KeyboardModifiers mods); + void dragCornerRadius(int which, const QPointF &localPos); + void dragPathAnchor (int which, const QPointF &localPos, Qt::KeyboardModifiers mods); + void dragPathControlHandle(bool isOutHandle, int nodeIndex, const QPointF &localPos, Qt::KeyboardModifiers mods); + void mirrorOppositeHandle(PathNode &node, bool justChangedIsOut); // shared by dragPathControlHandle() and dragCurveSegment() + void dragCurveSegment(int segmentIndex, qreal t, const QPointF &localPos); // Inkscape-style "grab the curve itself" + + void promoteRectangleOrEllipseToPolygon(int detachedResizeIndex, const QPointF &newLocalPos); + std::pair nearestPathSegment(const QPointF &localPos) const; // {segmentIndex, t}, -1 if fewer than 2 nodes + void insertPathPoint(int segmentIndex, qreal t); // De Casteljau split -- see .cpp for why + void removePathPoint(int nodeIndex); + QDomElement snapshotXml() const; // helper for PromoteShapeCommand: toXml() into a throwaway document + + QRectF localRect() const; + QPainterPath outline() const; // raw, unstroked path for the current type; shared by shape() and paint() + QVector currentHandlePositions() const; // in m_handleRoles/m_handleSlot order, local coordinates + QPointF handlePositionFor(HandleRole role, int slot) const; + QPointF rotateHandleReference(int slot) const; // the point a Rotate handle tracks, before rotation is applied + QPointF scaleOnlyOffset(const QPointF &localPoint) const; // (localPoint - pivot), scaled, in the pre-shear frame + QPointF scaleAndShearOffset(const QPointF &localPoint) const; // same, with current shear also applied -- the pre-rotation frame + static QColor colorForHandleRole(HandleRole role); + static QRectF lockAspectRatio(const QRectF &oldRect, QRectF newRect, int resizeIndex, bool mirrored); + static QPointF cornerPoint(const QRectF &rect, int cornerIndex); // 0=NW,1=NE,2=SE,3=SW + static QPointF edgeMidpoint(const QRectF &rect, int edgeIndex); // 0=N, 1=E, 2=S, 3=W + + ///ATTRIBUTES private: ShapeType m_shapeType; QPen m_pen; @@ -153,7 +306,6 @@ class QetShapeItem : public QetGraphicsItem int m_vector_index; bool m_closed = false, m_modifie_radius_equaly = false; - int m_resize_mode = 1; QVector m_handler_vector; QAction *m_insert_point, *m_remove_point; @@ -161,5 +313,26 @@ class QetShapeItem : public QetGraphicsItem m_yRadius = 0, m_old_xRadius, m_old_yRadius; + + ShapeTransform m_transform; + ShapeTransform m_old_transform; + HandleMode m_handleMode = HandleMode::Size; + QVector m_handleRoles; // parallel to m_handler_vector, one role per handle + QVector m_handleSlot; // parallel to m_handler_vector, meaning depends on role (see HandleRole) + QPointF m_old_pos; + + qreal m_startAngle = 0; + qreal m_endAngle = 360; + ArcClosure m_arcClosure = NoClosure; + bool m_pivotIsCustom = false; // false: pivot auto-follows the bounding-rect center on every geometry edit + bool m_deferHandleReposition = false; // true while setPivot() is applying its two related updates together + + QVector m_nodes; + QVector m_old_nodes; // saved at handle press, for undo + int m_curveDragSegment = -1; // >=0 while dragging the curve itself (not a handle) between two nodes + qreal m_curveDragT = 0; // parameter along that segment where the drag started + QPointF m_curveDragOriginalP1, m_curveDragOriginalP2; // absolute control points at drag start, fixed for the whole drag + QPointF m_curveDragPressPos; // local position at press, to tell a plain click from a real drag + bool m_curveDragEngaged = false; // false until the drag actually exceeds a small threshold }; #endif // QETSHAPEITEM_H diff --git a/sources/qetgraphicsitem/shapetransform.cpp b/sources/qetgraphicsitem/shapetransform.cpp new file mode 100644 index 000000000..b2eb98077 --- /dev/null +++ b/sources/qetgraphicsitem/shapetransform.cpp @@ -0,0 +1,88 @@ +/* + 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 "shapetransform.h" + +#include + +bool ShapeTransform::isIdentity() const +{ + return qFuzzyIsNull(rotation) && qFuzzyIsNull(skewX) && qFuzzyIsNull(skewY) + && qFuzzyCompare(scaleX, qreal(1)) && qFuzzyCompare(scaleY, qreal(1)); +} + +QTransform ShapeTransform::linearPart() const +{ + QTransform t; + t.rotate(rotation); + t.shear(qTan(qDegreesToRadians(skewX)), qTan(qDegreesToRadians(skewY))); + t.scale(scaleX, scaleY); + return t; +} + +QTransform ShapeTransform::toMatrix() const +{ + QTransform t; + t.translate(pivot.x(), pivot.y()); + t.rotate(rotation); + t.shear(qTan(qDegreesToRadians(skewX)), qTan(qDegreesToRadians(skewY))); + t.scale(scaleX, scaleY); + t.translate(-pivot.x(), -pivot.y()); + return t; +} + +bool ShapeTransform::operator==(const ShapeTransform &other) const +{ + return qFuzzyCompare(rotation, other.rotation) + && qFuzzyCompare(skewX, other.skewX) + && qFuzzyCompare(skewY, other.skewY) + && qFuzzyCompare(scaleX, other.scaleX) + && qFuzzyCompare(scaleY, other.scaleY) + && pivot == other.pivot; +} + +ShapeTransform decomposeLinear(const QTransform &m) +{ + const qreal scaleX = qSqrt(m.m11() * m.m11() + m.m12() * m.m12()); + const qreal rotation = qRadiansToDegrees(qAtan2(m.m12(), m.m11())); + const qreal rad = qDegreesToRadians(rotation); + const qreal c = qCos(rad), s = qSin(rad); + + // v * R(-rotation): undo the rotation, leaving the pure shear+scale + // contribution the local y-axis picked up from Shear(sh,0)*Scale(sx,sy). + const qreal vx = m.m21() * c + m.m22() * s; + const qreal scaleY = -m.m21() * s + m.m22() * c; + const qreal skewX = qFuzzyIsNull(scaleY) ? 0.0 : qRadiansToDegrees(qAtan(vx / scaleY)); + + ShapeTransform result; + result.rotation = rotation; + result.skewX = skewX; + result.skewY = 0; + result.scaleX = scaleX; + result.scaleY = scaleY; + return result; +} + +QPointF compensatedPositionForNewPivot( + const QPointF &position, + const QPointF &oldPivot, + const QPointF &newPivot, + const QTransform &linearPart) +{ + return position + (oldPivot - linearPart.map(oldPivot)) + - (newPivot - linearPart.map(newPivot)); +} diff --git a/sources/qetgraphicsitem/shapetransform.h b/sources/qetgraphicsitem/shapetransform.h new file mode 100644 index 000000000..b24fc3c74 --- /dev/null +++ b/sources/qetgraphicsitem/shapetransform.h @@ -0,0 +1,90 @@ +/* + 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 SHAPETRANSFORM_H +#define SHAPETRANSFORM_H + +#include +#include + +/** + @brief The ShapeTransform struct + Rotation, skew and scale applied to a shape's local geometry around an + arbitrary local pivot point. + + These five scalars plus a pivot are a convenient, directly-editable + basis for 2D affine transforms: every handle in the UI changes exactly + one field, which is what makes per-handle drag math simple. Note that + a plain 2x2 linear map only has 4 true degrees of freedom, one fewer + than (rotation, skewX, skewY, scaleX, scaleY) -- so this is not a + *unique* representation of a matrix, only a convenient one. That + redundancy is harmless for editing (nothing here ever needs to invert + the forward direction) and only matters to decomposeLinear() below, + which exists for later consumers (flattening a group transform onto + its children, importing a foreign matrix) rather than everyday use. + + The resulting matrix is meant to be handed directly to + QGraphicsItem::setTransform(). QGraphicsItem::transformOriginPoint() + is deliberately NOT used anywhere in this design: it only centers + QGraphicsItem's own rotation()/scale() convenience properties, and has + no effect on a custom transform() matrix -- the pivot has to be baked + into the matrix itself, as toMatrix() does. QGraphicsItem::pos() + supplies the translation on top, unchanged from how shapes are + already positioned and moved today. +*/ +struct ShapeTransform +{ + qreal rotation = 0; // degrees + qreal skewX = 0; // degrees + qreal skewY = 0; // degrees + qreal scaleX = 1; + qreal scaleY = 1; + QPointF pivot; // local coordinates; caller decides the default (usually local bbox center) + + bool isIdentity() const; + + // Rotate+shear+scale about the origin, ignoring pivot -- this is the + // "linear part" used by compensatedPositionForNewPivot() and by + // anything that needs to map a direction/offset rather than a point. + QTransform linearPart() const; + + // The pivot-centered matrix to pass to QGraphicsItem::setTransform(). + QTransform toMatrix() const; + + bool operator==(const ShapeTransform &other) const; + bool operator!=(const ShapeTransform &other) const { return !(*this == other); } +}; + +// Canonical decomposition of an arbitrary 2x2 linear map into the five +// scalars above, always returning skewY == 0 (all shear folded into +// skewX -- matching how most authoring tools represent shear on export). +// Rebuilding via ShapeTransform::linearPart() from the result reproduces +// the input matrix exactly; the individual scalar values are not +// guaranteed to match whatever scalars (if any) originally produced that +// matrix, only the matrix itself is guaranteed to match. +ShapeTransform decomposeLinear(const QTransform &linear); + +// Position adjustment needed when the pivot moves, so the shape does not +// visibly jump on screen: call once, at the end of a pivot-handle drag, +// alongside setting the new pivot. +QPointF compensatedPositionForNewPivot( + const QPointF &position, + const QPointF &oldPivot, + const QPointF &newPivot, + const QTransform &linearPart); + +#endif // SHAPETRANSFORM_H diff --git a/sources/qeticons.cpp b/sources/qeticons.cpp index 6d53c46c6..9cde7a0b2 100644 --- a/sources/qeticons.cpp +++ b/sources/qeticons.cpp @@ -128,6 +128,7 @@ namespace QET { QIcon ObjectUnlocked; QIcon Orientations; QIcon PartArc; + QIcon PartBezier; QIcon PartCircle; QIcon PartEllipse; QIcon PartLine; @@ -570,6 +571,7 @@ void QET::Icons::initIcons() ObjectUnlocked .addFile(":/ico/22x22/object-unlocked.png"); Orientations .addFile(":/ico/16x16/orientations.png"); PartArc .addFile(":/ico/22x22/arc.png"); + PartBezier .addFile(":/ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg"); PartCircle .addFile(":/ico/16x16/circle.png"); PartEllipse .addFile(":/ico/22x22/ellipse.png"); PartLine .addFile(":/ico/22x22/line.png"); diff --git a/sources/qeticons.h b/sources/qeticons.h index a81c79e17..5c0e981da 100644 --- a/sources/qeticons.h +++ b/sources/qeticons.h @@ -136,6 +136,7 @@ namespace QET { extern QIcon ObjectUnlocked; extern QIcon Orientations; extern QIcon PartArc; + extern QIcon PartBezier; extern QIcon PartCircle; extern QIcon PartEllipse; extern QIcon PartLine; diff --git a/sources/ui/imagecropdialog.cpp b/sources/ui/imagecropdialog.cpp new file mode 100644 index 000000000..9f2e3a8dc --- /dev/null +++ b/sources/ui/imagecropdialog.cpp @@ -0,0 +1,287 @@ +/* + 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 "imagecropdialog.h" + +#include +#include +#include +#include +#include +#include + +namespace { + // Fits the dialog comfortably on a normal screen regardless of the + // source image's own resolution -- same reasoning, and same value, + // as ImageTransparentColorDialog's identical constant. + constexpr int MAX_DISPLAY_SIZE = 350; +} + +/** + @brief CropAreaWidget::CropAreaWidget + @param sourceImage the full-resolution image to crop + @param initialCropRect a previously-chosen crop rect (in + sourceImage's own coordinates), or an empty QRect to start covering + the whole image + @param parent +*/ +CropAreaWidget::CropAreaWidget(const QImage &sourceImage, const QRect &initialCropRect, QWidget *parent) : + QWidget(parent), + m_source(sourceImage) +{ + const qreal scaleW = qreal(MAX_DISPLAY_SIZE) / m_source.width(); + const qreal scaleH = qreal(MAX_DISPLAY_SIZE) / m_source.height(); + m_displayScale = qMin(qreal(1.0), qMin(scaleW, scaleH)); // never upscale a small image, only ever shrink a large one + + const QSize displaySize = m_source.size() * m_displayScale; + m_displayImageRect = QRect(QPoint(0, 0), displaySize); + setFixedSize(displaySize); + + if (initialCropRect.isEmpty()) + { + // No previous crop to start from -- covers the whole image, so + // the user shrinks it from there rather than having to first + // drag out a rectangle from nothing. + m_cropRect = m_displayImageRect; + } + else + { + // Converting an existing, original-space crop rect back into + // display-space -- the reverse of what cropRect() does going + // the other way. + m_cropRect = QRect( + qRound(initialCropRect.left() * m_displayScale), + qRound(initialCropRect.top() * m_displayScale), + qRound(initialCropRect.width() * m_displayScale), + qRound(initialCropRect.height() * m_displayScale)) + .intersected(m_displayImageRect); + } +} + +/** + @brief CropAreaWidget::resetToFullImage +*/ +void CropAreaWidget::resetToFullImage() +{ + m_cropRect = m_displayImageRect; + update(); + emit cropRectChanged(); +} + +/** + @brief CropAreaWidget::cropRect + @return the crop rectangle in the original, full-resolution image's + own coordinates -- m_cropRect itself is display-space only. +*/ +QRect CropAreaWidget::cropRect() const +{ + QRect r( + qRound(m_cropRect.left() / m_displayScale), + qRound(m_cropRect.top() / m_displayScale), + qRound(m_cropRect.width() / m_displayScale), + qRound(m_cropRect.height() / m_displayScale)); + return r.intersected(m_source.rect()); +} + +/** + @brief CropAreaWidget::handleAt + @param pos widget-space position to test + @return whichever handle (or the rect's own interior, for moving) + pos is close enough to, or DragMode::None if it's not near anything +*/ +CropAreaWidget::DragMode CropAreaWidget::handleAt(const QPoint &pos) const +{ + const int tol = HANDLE_SIZE; // a generous hit area, larger than the drawn handle itself, since a hit-test exactly matching a small handle's visible size is needlessly hard to land + auto near = [&](const QPoint &p) { return (pos - p).manhattanLength() <= tol; }; + + if (near(m_cropRect.topLeft())) return DragMode::ResizeNW; + if (near(m_cropRect.topRight())) return DragMode::ResizeNE; + if (near(m_cropRect.bottomLeft())) return DragMode::ResizeSW; + if (near(m_cropRect.bottomRight())) return DragMode::ResizeSE; + if (near(QPoint(m_cropRect.center().x(), m_cropRect.top()))) return DragMode::ResizeN; + if (near(QPoint(m_cropRect.center().x(), m_cropRect.bottom()))) return DragMode::ResizeS; + if (near(QPoint(m_cropRect.left(), m_cropRect.center().y()))) return DragMode::ResizeW; + if (near(QPoint(m_cropRect.right(), m_cropRect.center().y()))) return DragMode::ResizeE; + + if (m_cropRect.contains(pos)) + return DragMode::Move; + + return DragMode::None; +} + +/** + @brief CropAreaWidget::applyDrag + Recomputes m_cropRect from m_dragStartRect plus however far pos has + moved from m_dragStartPos, according to m_dragMode -- always + clamped to stay within the displayed image and never smaller than + MIN_CROP_SIZE, so the rect can never escape its bounds or collapse + to nothing while being dragged. + @param pos current widget-space mouse position +*/ +void CropAreaWidget::applyDrag(const QPoint &pos) +{ + const QPoint delta = pos - m_dragStartPos; + QRect r = m_dragStartRect; + + auto clampLeft = [&](int v) { r.setLeft(qBound(m_displayImageRect.left(), v, r.right() - MIN_CROP_SIZE)); }; + auto clampRight = [&](int v) { r.setRight(qBound(r.left() + MIN_CROP_SIZE, v, m_displayImageRect.right())); }; + auto clampTop = [&](int v) { r.setTop(qBound(m_displayImageRect.top(), v, r.bottom() - MIN_CROP_SIZE)); }; + auto clampBottom = [&](int v) { r.setBottom(qBound(r.top() + MIN_CROP_SIZE, v, m_displayImageRect.bottom())); }; + + switch (m_dragMode) + { + case DragMode::Move: + { + // Clamping the translation itself, rather than each edge of + // the resulting rect independently -- clamping edges one at + // a time would shrink or distort the rect the instant it + // hit a boundary, instead of just stopping its movement. + QRect moved = m_dragStartRect.translated(delta); + if (moved.left() < m_displayImageRect.left()) + moved.translate(m_displayImageRect.left() - moved.left(), 0); + if (moved.right() > m_displayImageRect.right()) + moved.translate(m_displayImageRect.right() - moved.right(), 0); + if (moved.top() < m_displayImageRect.top()) + moved.translate(0, m_displayImageRect.top() - moved.top()); + if (moved.bottom() > m_displayImageRect.bottom()) + moved.translate(0, m_displayImageRect.bottom() - moved.bottom()); + r = moved; + break; + } + case DragMode::ResizeN: clampTop(m_dragStartRect.top() + delta.y()); break; + case DragMode::ResizeS: clampBottom(m_dragStartRect.bottom() + delta.y()); break; + case DragMode::ResizeE: clampRight(m_dragStartRect.right() + delta.x()); break; + case DragMode::ResizeW: clampLeft(m_dragStartRect.left() + delta.x()); break; + case DragMode::ResizeNE: clampTop(m_dragStartRect.top() + delta.y()); clampRight(m_dragStartRect.right() + delta.x()); break; + case DragMode::ResizeNW: clampTop(m_dragStartRect.top() + delta.y()); clampLeft(m_dragStartRect.left() + delta.x()); break; + case DragMode::ResizeSE: clampBottom(m_dragStartRect.bottom() + delta.y()); clampRight(m_dragStartRect.right() + delta.x()); break; + case DragMode::ResizeSW: clampBottom(m_dragStartRect.bottom() + delta.y()); clampLeft(m_dragStartRect.left() + delta.x()); break; + case DragMode::None: return; + } + + m_cropRect = r; +} + +void CropAreaWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton) + return; + m_dragMode = handleAt(event->pos()); + m_dragStartPos = event->pos(); + m_dragStartRect = m_cropRect; +} + +void CropAreaWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (m_dragMode == DragMode::None) + return; + applyDrag(event->pos()); + update(); + emit cropRectChanged(); +} + +void CropAreaWidget::mouseReleaseEvent(QMouseEvent *event) +{ + Q_UNUSED(event); + m_dragMode = DragMode::None; +} + +/** + @brief CropAreaWidget::paintEvent + Draws the image, dims everything outside the crop rect (four + strips around it, rather than one overlay with a hole cut out of + it, since that would need a more involved clip/composition setup + for no real benefit here), then the rect's own dashed border and + its eight handles. +*/ +void CropAreaWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.drawImage(m_displayImageRect, m_source); + + const QColor dim(0, 0, 0, 140); + painter.fillRect(QRect(m_displayImageRect.left(), m_displayImageRect.top(), + m_displayImageRect.width(), m_cropRect.top() - m_displayImageRect.top()), dim); + painter.fillRect(QRect(m_displayImageRect.left(), m_cropRect.bottom() + 1, + m_displayImageRect.width(), m_displayImageRect.bottom() - m_cropRect.bottom()), dim); + painter.fillRect(QRect(m_displayImageRect.left(), m_cropRect.top(), + m_cropRect.left() - m_displayImageRect.left(), m_cropRect.height()), dim); + painter.fillRect(QRect(m_cropRect.right() + 1, m_cropRect.top(), + m_displayImageRect.right() - m_cropRect.right(), m_cropRect.height()), dim); + + QPen borderPen(Qt::white); + borderPen.setStyle(Qt::DashLine); + painter.setPen(borderPen); + painter.setBrush(Qt::NoBrush); + painter.drawRect(m_cropRect); + + painter.setPen(Qt::black); + painter.setBrush(Qt::white); + auto drawHandle = [&](const QPoint ¢er) { + painter.drawRect(QRect(center.x() - HANDLE_SIZE / 2, center.y() - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)); + }; + drawHandle(m_cropRect.topLeft()); + drawHandle(m_cropRect.topRight()); + drawHandle(m_cropRect.bottomLeft()); + drawHandle(m_cropRect.bottomRight()); + drawHandle(QPoint(m_cropRect.center().x(), m_cropRect.top())); + drawHandle(QPoint(m_cropRect.center().x(), m_cropRect.bottom())); + drawHandle(QPoint(m_cropRect.left(), m_cropRect.center().y())); + drawHandle(QPoint(m_cropRect.right(), m_cropRect.center().y())); +} + +/** + @brief ImageCropDialog::ImageCropDialog + @param pixmap the image to crop + @param existingCropRect a crop rectangle remembered from a previous + session, or an empty QRect if there isn't one yet + @param parent +*/ +ImageCropDialog::ImageCropDialog(const QPixmap &pixmap, const QRect &existingCropRect, QWidget *parent) : + QDialog(parent) +{ + setWindowTitle(tr("Rogner l'image")); + + m_cropArea = new CropAreaWidget(pixmap.toImage(), existingCropRect, this); + + auto *hint = new QLabel(tr("Faites glisser les poignées, ou l'intérieur du cadre, pour ajuster la zone à conserver."), this); + hint->setWordWrap(true); + + auto *resetButton = new QPushButton(tr("Réinitialiser"), this); + resetButton->setToolTip(tr("Revenir à l'image complète, sans rognage")); + connect(resetButton, &QPushButton::clicked, m_cropArea, &CropAreaWidget::resetToFullImage); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttons->addButton(resetButton, QDialogButtonBox::ResetRole); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(hint); + layout->addWidget(m_cropArea, 0, Qt::AlignHCenter); + layout->addWidget(buttons); + + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +/** + @brief ImageCropDialog::cropRect + @return the chosen crop rectangle, in the original pixmap's own coordinates +*/ +QRect ImageCropDialog::cropRect() const +{ + return m_cropArea->cropRect(); +} diff --git a/sources/ui/imagecropdialog.h b/sources/ui/imagecropdialog.h new file mode 100644 index 000000000..28256d3a5 --- /dev/null +++ b/sources/ui/imagecropdialog.h @@ -0,0 +1,121 @@ +/* + 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 IMAGE_CROP_DIALOG_H +#define IMAGE_CROP_DIALOG_H + +#include +#include +#include +#include + +class QMouseEvent; +class QPaintEvent; +class QPushButton; + +/** + @brief The CropAreaWidget class + Displays the image scaled to fit a reasonable dialog size, with a + draggable, resizable crop rectangle overlaid on top -- the area + outside it dimmed, the usual visual convention for "this part goes + away" during a crop, so it's never ambiguous which region survives. + All internal geometry (the crop rect, drag handling, hit-testing) + works in display-space pixel coordinates; only cropRect() converts + back to the original, full-resolution image's own coordinates for + the caller, the same original-vs-display distinction already used + for colour sampling in ImageTransparentColorDialog. +*/ +class CropAreaWidget : public QWidget +{ + Q_OBJECT + + public: + /// @param sourceImage the full, uncropped image to crop from + /// @param initialCropRect a previously-chosen crop rectangle, in + /// sourceImage's own coordinates, to start from -- an empty + /// (default-constructed) QRect means "no previous crop", i.e. + /// start covering the whole image. + explicit CropAreaWidget(const QImage &sourceImage, const QRect &initialCropRect = QRect(), QWidget *parent = nullptr); + + /// The current crop rectangle, in the original image's own + /// coordinates (not display-space) -- always clamped to the + /// image's own bounds and never degenerate (zero width/height). + QRect cropRect() const; + + public slots: + /// Resets the crop rectangle back to covering the entire image -- + /// the direct answer to "I want to undo the crop, not just + /// adjust it", since dragging every handle back out by hand to + /// exactly the original extent is needlessly fiddly. + void resetToFullImage(); + + signals: + void cropRectChanged(); + + protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + + private: + enum class DragMode { None, Move, ResizeN, ResizeS, ResizeE, ResizeW, ResizeNE, ResizeNW, ResizeSE, ResizeSW }; + + DragMode handleAt(const QPoint &pos) const; + void applyDrag(const QPoint &pos); + + QImage m_source; + qreal m_displayScale = 1.0; + QRect m_displayImageRect; // where the (possibly scaled) pixmap actually sits within this widget + QRect m_cropRect; // in display-space, relative to m_displayImageRect's own origin + + DragMode m_dragMode = DragMode::None; + QPoint m_dragStartPos; + QRect m_dragStartRect; + + static constexpr int HANDLE_SIZE = 8; + static constexpr int MIN_CROP_SIZE = 16; +}; + +/** + @brief The ImageCropDialog class + Self-contained modal dialog wrapping CropAreaWidget with OK/Cancel -- + the same reasoning as ImageTransparentColorDialog: this needs + neither undo-during-drag nor coexistence with other tools, only a + rectangle the user drags out against a pixmap already in hand. +*/ +class ImageCropDialog : public QDialog +{ + Q_OBJECT + + public: + /// @param pixmap the full, uncropped image to crop from + /// @param existingCropRect a crop rectangle remembered from a + /// previous session, shown from the start rather than resetting + /// to the full image every time the dialog is reopened -- an + /// empty QRect means there isn't one yet. + explicit ImageCropDialog(const QPixmap &pixmap, const QRect &existingCropRect = QRect(), QWidget *parent = nullptr); + + /// The chosen crop rectangle, in the original pixmap's own + /// coordinates. Only meaningful after the dialog was accepted. + QRect cropRect() const; + + private: + CropAreaWidget *m_cropArea; +}; + +#endif // IMAGE_CROP_DIALOG_H diff --git a/sources/ui/imagepropertieswidget.cpp b/sources/ui/imagepropertieswidget.cpp index c6437fa14..b2cccca2b 100644 --- a/sources/ui/imagepropertieswidget.cpp +++ b/sources/ui/imagepropertieswidget.cpp @@ -20,6 +20,7 @@ #include "../QPropertyUndoCommand/qpropertyundocommand.h" #include "../diagram.h" #include "../qetgraphicsitem/diagramimageitem.h" +#include "../qeticons.h" #include "../ui_imagepropertieswidget.h" /** @@ -34,6 +35,11 @@ ImagePropertiesWidget::ImagePropertiesWidget(DiagramImageItem *image, QWidget *p m_image(nullptr) { ui->setupUi(this); + // Matches the .ui file's own initial checked="true" -- toggled() + // only fires on a subsequent CHANGE, not for the state a widget + // already starts in, so the icon needs setting explicitly here or + // the button would show blank until first clicked. + ui->m_lock_ratio_tb->setIcon(QET::Icons::ObjectLocked); this->setDisabled(true); setImageItem(image); } @@ -58,12 +64,16 @@ void ImagePropertiesWidget::setImageItem(DiagramImageItem *image) this->setEnabled(true); if (m_image == image) return; if (m_image) - disconnect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi); + disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); m_image = image; - connect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi); + connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); m_movable = image->isMovable(); - m_scale = m_image->scale(); + m_scaleX = m_image->scaleFactorX(); + m_scaleY = m_image->scaleFactorY(); + m_rotation = m_image->rotationAngle(); + m_skewX = m_image->skewX(); + m_skewY = m_image->skewY(); updateUi(); } @@ -77,16 +87,20 @@ void ImagePropertiesWidget::apply() if (m_image->diagram()) { - if (m_live_edit) disconnect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi); + if (m_live_edit) disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); QUndoCommand *undo = associatedUndo(); if (undo) m_image->diagram()->undoStack().push(undo); - if (m_live_edit) connect(m_image, &QGraphicsObject::scaleChanged, this, &ImagePropertiesWidget::updateUi); + if (m_live_edit) connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); } - m_scale = m_image->scale(); + m_scaleX = m_image->scaleFactorX(); + m_scaleY = m_image->scaleFactorY(); + m_rotation = m_image->rotationAngle(); + m_skewX = m_image->skewX(); + m_skewY = m_image->skewY(); } /** @@ -97,7 +111,11 @@ void ImagePropertiesWidget::reset() { if(!m_image) return; - m_image->setScale(m_scale); + m_image->setScaleFactorX(m_scaleX); + m_image->setScaleFactorY(m_scaleY); + m_image->setRotationAngle(m_rotation); + m_image->setSkewX(m_skewX); + m_image->setSkewY(m_skewY); m_image->setMovable(m_movable); updateUi(); } @@ -115,13 +133,19 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) if (m_live_edit) { - connect (ui->m_scale_slider, &QSlider::sliderReleased, this, &ImagePropertiesWidget::apply); - connect (ui->m_scale_sb, &QSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + connect (ui->m_width_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + connect (ui->m_height_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + connect (ui->m_angle_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + connect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + connect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); } else { - disconnect (ui->m_scale_slider, &QSlider::sliderReleased, this, &ImagePropertiesWidget::apply); - disconnect (ui->m_scale_sb, &QSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + disconnect (ui->m_width_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + disconnect (ui->m_height_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + disconnect (ui->m_angle_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + disconnect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + disconnect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); } return true; @@ -129,18 +153,44 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) /** @brief ImagePropertiesWidget::associatedUndo - @return the change in an undo command (ItemResizerCommand). - If there is no change return nullptr + @return the change in an undo command. If there is no change return + nullptr. Chains scaleFactorX, scaleFactorY, rotationAngle, skewX and + skewY as separate QPropertyUndoCommands, matching the established + "check each field, chain if changed" pattern used elsewhere -- each + of these is genuinely independent (the resize/rotate/skew handles + can set any of them without touching the others), so a single + combined command wouldn't correctly capture "only two of five + fields actually changed". */ QUndoCommand* ImagePropertiesWidget::associatedUndo() const { + const qreal newScaleX = ui->m_width_sb->value() / 100.0; + const qreal newScaleY = ui->m_height_sb->value() / 100.0; + const qreal newRotation = ui->m_angle_sb->value(); + const qreal newSkewX = ui->m_skew_x_sb->value(); + const qreal newSkewY = ui->m_skew_y_sb->value(); + + QPropertyUndoCommand *undo = nullptr; + auto chain = [&](const char *property, qreal oldValue, qreal newValue, const QString &text) + { + if (qFuzzyCompare(oldValue, newValue)) + return; + if (undo) + new QPropertyUndoCommand(m_image, property, oldValue, newValue, undo); + else + { + undo = new QPropertyUndoCommand(m_image, property, oldValue, newValue); + undo->enableAnimation(); + undo->setText(text); + } + }; + + chain("scaleFactorX", m_scaleX, newScaleX, tr("Modifier la largeur d'une image")); + chain("scaleFactorY", m_scaleY, newScaleY, tr("Modifier la hauteur d'une image")); + chain("rotationAngle", m_rotation, newRotation, tr("Modifier l'angle d'une image")); + chain("skewX", m_skewX, newSkewX, tr("Modifier l'inclinaison d'une image")); + chain("skewY", m_skewY, newSkewY, tr("Modifier l'inclinaison d'une image")); - qreal value = ui->m_scale_slider->value(); - value /= 100; - if (m_scale == value) return nullptr; - QPropertyUndoCommand *undo = new QPropertyUndoCommand(m_image, "scale", m_scale, value); - undo->enableAnimation(); - undo->setText(tr("Modifier la taille d'une image")); return undo; } @@ -150,20 +200,167 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const */ void ImagePropertiesWidget::updateUi() { - if (!m_image) return; - ui->m_scale_slider->setValue(m_image->scale() * 100); + // Two different uses of the same flag: an entry guard here (skip + // entirely if a locked width/height operation is already in + // progress elsewhere -- its own explicit updateUi() call once + // finished is what actually refreshes things), and, immediately + // below, a re-entrancy guard around this function's OWN setValue() + // calls, so they can't recurse back into + // on_m_width_sb_valueChanged()/on_m_height_sb_valueChanged() when + // this runs normally (e.g. from setImageItem(), or a transformChanged + // signal from a resize handle used directly on the canvas while + // this dialog is open). + if (!m_image || m_updating_ratio) return; + + m_updating_ratio = true; + ui->m_width_sb->setValue(m_image->scaleFactorX() * 100.0); + ui->m_height_sb->setValue(m_image->scaleFactorY() * 100.0); + m_updating_ratio = false; + + ui->m_angle_sb->setValue(m_image->rotationAngle()); + ui->m_skew_x_sb->setValue(m_image->skewX()); + ui->m_skew_y_sb->setValue(m_image->skewY()); ui->m_lock_pos_cb->setChecked(!m_image->isMovable()); } /** - @brief ImagePropertiesWidget::on_m_scale_slider_valueChanged - Update the size of image when move slider. - @param value + @brief ImagePropertiesWidget::on_m_lock_ratio_tb_toggled + Keeps the button's own icon and tooltip in sync with its checked + state -- Qt shows the checked/unchecked state visually (a sunken + look) regardless, but the lock/unlock glyph makes the meaning + obvious at a glance rather than needing that convention noticed. + @param checked */ -void ImagePropertiesWidget::on_m_scale_slider_valueChanged(int value) +void ImagePropertiesWidget::on_m_lock_ratio_tb_toggled(bool checked) { - qreal scale = value; - m_image->setScale(scale / 100); + ui->m_lock_ratio_tb->setIcon(checked ? QET::Icons::ObjectLocked : QET::Icons::ObjectUnlocked); + ui->m_lock_ratio_tb->setToolTip(checked + ? tr("Verrouillé : modifier la largeur ou la hauteur ajuste l'autre pour conserver les proportions. Cliquer pour déverrouiller.") + : tr("Déverrouillé : largeur et hauteur peuvent être modifiées indépendamment. Cliquer pour verrouiller.")); +} + +/** + @brief ImagePropertiesWidget::on_m_restore_ratio_pb_clicked + The same action as the item's own "Restaurer les proportions" + context-menu entry, offered here too since this dialog is exactly + where someone would notice the proportions are off in the first + place. Deliberately bypasses on_m_height_sb_valueChanged()'s own + lock-following logic (guarded by m_updating_ratio, then applied + directly rather than through the spinbox's own signal) rather than + going through it normally: if the lock happened to be engaged, + that logic would treat this as an ordinary edit and scale width by + the same ratio the height change just applied -- compounding into + some other, unrelated value instead of the plain "make height + match width" this button promises regardless of lock state. +*/ +void ImagePropertiesWidget::on_m_restore_ratio_pb_clicked() +{ + if (!m_image) return; + const qreal newScaleY = m_image->scaleFactorX(); + m_updating_ratio = true; + ui->m_height_sb->setValue(newScaleY * 100.0); + m_updating_ratio = false; + m_image->setScaleFactorY(newScaleY); + apply(); +} + +/** + @brief ImagePropertiesWidget::on_m_width_sb_valueChanged + Applies the new width live, and -- if "Conserver les proportions" is + checked -- scales height by the same ratio this change just applied + to width, so the two stay locked together. Guarded by + m_updating_ratio: the setValue() call below would otherwise + re-trigger on_m_height_sb_valueChanged(), which would then try to + scale width again in response, recursing indefinitely. + @param value the new width, as a percentage +*/ +void ImagePropertiesWidget::on_m_width_sb_valueChanged(double value) +{ + if (!m_image || m_updating_ratio) return; + + const qreal oldScaleX = m_image->scaleFactorX(); + const qreal newScaleX = value / 100.0; + const bool wasLocked = ui->m_lock_ratio_tb->isChecked(); + + // Guards updateUi() itself here, not just the mutual spinbox + // updates below: setScaleFactorX() emits transformChanged() + // immediately, synchronously calling updateUi() before this + // function gets any further -- at that exact moment scaleX has + // already changed but scaleY hasn't caught up yet, so updateUi()'s + // "auto-unlock if currently mismatched" check would fire and + // uncheck the lock before the compensating height change below + // ever runs, defeating the lock on every single use. Released + // before the explicit updateUi() call at the end, once both axes + // genuinely agree again (if locked) and that check is meaningful. + m_updating_ratio = true; + m_image->setScaleFactorX(newScaleX); + + if (wasLocked && !qFuzzyIsNull(oldScaleX)) + { + const qreal ratio = newScaleX / oldScaleX; + const qreal newScaleY = m_image->scaleFactorY() * ratio; + ui->m_height_sb->setValue(newScaleY * 100.0); + m_image->setScaleFactorY(newScaleY); + } + m_updating_ratio = false; + updateUi(); +} + +/** + @brief ImagePropertiesWidget::on_m_height_sb_valueChanged + Mirror of on_m_width_sb_valueChanged() -- see its comment. + @param value the new height, as a percentage +*/ +void ImagePropertiesWidget::on_m_height_sb_valueChanged(double value) +{ + if (!m_image || m_updating_ratio) return; + + const qreal oldScaleY = m_image->scaleFactorY(); + const qreal newScaleY = value / 100.0; + const bool wasLocked = ui->m_lock_ratio_tb->isChecked(); + + m_updating_ratio = true; // see on_m_width_sb_valueChanged()'s identical comment + m_image->setScaleFactorY(newScaleY); + + if (wasLocked && !qFuzzyIsNull(oldScaleY)) + { + const qreal ratio = newScaleY / oldScaleY; + const qreal newScaleX = m_image->scaleFactorX() * ratio; + ui->m_width_sb->setValue(newScaleX * 100.0); + m_image->setScaleFactorX(newScaleX); + } + m_updating_ratio = false; + updateUi(); +} + +/** + @brief ImagePropertiesWidget::on_m_angle_sb_valueChanged + @param value the new rotation, in degrees +*/ +void ImagePropertiesWidget::on_m_angle_sb_valueChanged(double value) +{ + if (!m_image) return; + m_image->setRotationAngle(value); +} + +/** + @brief ImagePropertiesWidget::on_m_skew_x_sb_valueChanged + @param value the new X skew, in degrees +*/ +void ImagePropertiesWidget::on_m_skew_x_sb_valueChanged(double value) +{ + if (!m_image) return; + m_image->setSkewX(value); +} + +/** + @brief ImagePropertiesWidget::on_m_skew_y_sb_valueChanged + @param value the new Y skew, in degrees +*/ +void ImagePropertiesWidget::on_m_skew_y_sb_valueChanged(double value) +{ + if (!m_image) return; + m_image->setSkewY(value); } /** diff --git a/sources/ui/imagepropertieswidget.h b/sources/ui/imagepropertieswidget.h index 0cd803e89..438b5e3b8 100644 --- a/sources/ui/imagepropertieswidget.h +++ b/sources/ui/imagepropertieswidget.h @@ -48,14 +48,33 @@ class ImagePropertiesWidget : public PropertiesEditorWidget void updateUi() override; private slots: - void on_m_scale_slider_valueChanged(int value); + void on_m_width_sb_valueChanged(double value); + void on_m_height_sb_valueChanged(double value); + void on_m_angle_sb_valueChanged(double value); + void on_m_skew_x_sb_valueChanged(double value); + void on_m_skew_y_sb_valueChanged(double value); + void on_m_lock_ratio_tb_toggled(bool checked); + void on_m_restore_ratio_pb_clicked(); void on_m_lock_pos_cb_clicked(); private: Ui::ImagePropertiesWidget *ui; DiagramImageItem *m_image; bool m_movable; - qreal m_scale; + // All tracked independently -- the image may already be + // non-uniformly scaled, rotated, and/or skewed via the resize/ + // rotate/skew handles before this dialog is even opened, and + // undo/reset have to restore each to whatever it actually was. + qreal m_scaleX; + qreal m_scaleY; + qreal m_rotation; + qreal m_skewX; + qreal m_skewY; + // Guards the width/height spinboxes' mutual updates when + // "Conserver les proportions" is checked, so setting one + // programmatically in response to the other doesn't re-trigger + // its own valueChanged and recurse. + bool m_updating_ratio = false; }; #endif // IMAGEPROPERTIESWIDGET_H diff --git a/sources/ui/imagepropertieswidget.ui b/sources/ui/imagepropertieswidget.ui index f3bd2bcda..36ff7ddf3 100644 --- a/sources/ui/imagepropertieswidget.ui +++ b/sources/ui/imagepropertieswidget.ui @@ -6,8 +6,8 @@ 0 0 - 244 - 105 + 280 + 230 @@ -17,47 +17,149 @@ QLayout::SetMinimumSize - + + + + Largeur + + + + + + + % + + + 1 + + + 1.000000000000000 + + + 1000.000000000000000 + + + + + + + Verrouillé : modifier la largeur ou la hauteur ajuste l'autre pour conserver les proportions. Cliquer pour déverrouiller. + + + true + + + true + + + + + + + Hauteur + + + + + + + % + + + 1 + + + 1.000000000000000 + + + 1000.000000000000000 + + + + + + + Restaurer les proportions + + + + + + + Angle + + + + + + + ° + + + 1 + + + -360.000000000000000 + + + 360.000000000000000 + + + + + + + Inclinaison X + + + + + + + ° + + + 1 + + + -85.000000000000000 + + + 85.000000000000000 + + + + + + + Inclinaison Y + + + + + + + ° + + + 1 + + + -85.000000000000000 + + + 85.000000000000000 + + + + Verrouiller la position - - - - Dimension de l'image - - - - - - - 1 - - - 400 - - - Qt::Horizontal - - - - - - - % - - - 1 - - - 400 - - - - + Qt::Vertical @@ -73,38 +175,5 @@ - - - m_scale_slider - valueChanged(int) - m_scale_sb - setValue(int) - - - 81 - 40 - - - 190 - 40 - - - - - m_scale_sb - valueChanged(int) - m_scale_slider - setValue(int) - - - 190 - 40 - - - 81 - 40 - - - - + diff --git a/sources/ui/imagetransparentcolordialog.cpp b/sources/ui/imagetransparentcolordialog.cpp new file mode 100644 index 000000000..62bcf8813 --- /dev/null +++ b/sources/ui/imagetransparentcolordialog.cpp @@ -0,0 +1,335 @@ +/* + 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 "imagetransparentcolordialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + // Fits the dialog comfortably on a normal screen regardless of the + // source image's own resolution -- a photo straight off a phone + // would otherwise make this dialog enormous. + constexpr int MAX_DISPLAY_SIZE = 350; +} + +/** + @brief ClickableImageLabel::ClickableImageLabel + @param sourceImage the full-resolution image to display and sample from + @param parent +*/ +ClickableImageLabel::ClickableImageLabel(const QImage &sourceImage, QWidget *parent) : + QLabel(parent), + m_source(sourceImage) +{ + const qreal scaleW = qreal(MAX_DISPLAY_SIZE) / m_source.width(); + const qreal scaleH = qreal(MAX_DISPLAY_SIZE) / m_source.height(); + m_displayScale = qMin(qreal(1.0), qMin(scaleW, scaleH)); // never upscale a small image, only ever shrink a large one + + const QImage displayImage = (m_displayScale < 1.0) + ? m_source.scaled(m_source.size() * m_displayScale, Qt::KeepAspectRatio, Qt::SmoothTransformation) + : m_source; + setPixmap(QPixmap::fromImage(displayImage)); + setCursor(Qt::CrossCursor); + setToolTip(tr("Cliquez pour choisir une couleur")); +} + +/** + @brief ClickableImageLabel::mousePressEvent + Maps the click back to the original, full-resolution image before + sampling -- see the class-level comment for why. + @param event +*/ +void ClickableImageLabel::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || pixmap().isNull()) + return; + + // The label may be larger than its pixmap (layout stretching); the + // pixmap itself is always drawn centered by Qt's default alignment, + // so the click has to be re-based against the pixmap's own rect + // within the label, not the label's own top-left. + const QRect pixmapRect( + (width() - pixmap().width()) / 2, + (height() - pixmap().height()) / 2, + pixmap().width(), pixmap().height()); + if (!pixmapRect.contains(event->pos())) + return; + + const QPoint withinPixmap = event->pos() - pixmapRect.topLeft(); + QPoint originalPos(qRound(withinPixmap.x() / m_displayScale), qRound(withinPixmap.y() / m_displayScale)); + originalPos.setX(qBound(0, originalPos.x(), m_source.width() - 1)); + originalPos.setY(qBound(0, originalPos.y(), m_source.height() - 1)); + + emit colorPicked(m_source.pixelColor(originalPos)); +} + +/** + @brief ImageTransparentColorDialog::ImageTransparentColorDialog + @param pixmap the image to pick a transparent colour from + @param parent +*/ +ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixmap, const QList &existingColors, + int existingTolerance, QWidget *parent) : + QDialog(parent), + m_sourceImage(basePixmap.toImage()), + m_pickedColors(existingColors), + m_tolerance(existingTolerance) +{ + setWindowTitle(tr("Couleur transparente")); + + m_sourceLabel = new ClickableImageLabel(m_sourceImage, this); + m_previewLabel = new QLabel(this); + + m_hintLabel = new QLabel(this); + + m_toleranceSlider = new QSlider(Qt::Horizontal, this); + m_toleranceSlider->setRange(0, 100); + m_toleranceSlider->setValue(m_tolerance); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + m_okButton = buttons->button(QDialogButtonBox::Ok); + m_okButton->setEnabled(!m_pickedColors.isEmpty()); // nothing to apply until at least one colour has been picked + + auto *grid = new QGridLayout; + grid->addWidget(new QLabel(tr("Image source")), 0, 0); + grid->addWidget(new QLabel(tr("Aperçu")), 0, 1); + grid->addWidget(m_sourceLabel, 1, 0); + grid->addWidget(m_previewLabel, 1, 1); + + // An empty row to start with if existingColors is empty -- + // rebuildSwatches() below populates it either way (including from + // existingColors on the first call), and again as colours get + // added or removed. + m_swatchesLayout = new QHBoxLayout; + + auto *colorRow = new QHBoxLayout; + colorRow->addWidget(m_hintLabel); + colorRow->addStretch(); + colorRow->addLayout(m_swatchesLayout); + + auto *toleranceRow = new QHBoxLayout; + toleranceRow->addWidget(new QLabel(tr("Tolérance"))); + toleranceRow->addWidget(m_toleranceSlider); + + auto *mainLayout = new QVBoxLayout(this); + mainLayout->addLayout(grid); + mainLayout->addLayout(colorRow); + mainLayout->addLayout(toleranceRow); + mainLayout->addWidget(buttons); + + connect(m_sourceLabel, &ClickableImageLabel::colorPicked, this, &ImageTransparentColorDialog::onColorPicked); + connect(m_toleranceSlider, &QSlider::valueChanged, this, &ImageTransparentColorDialog::onToleranceChanged); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + rebuildSwatches(); // shows existingColors immediately, if any + updatePreview(); // and the preview already reflects them too, rather than only appearing after the next pick +} + +/** + @brief ImageTransparentColorDialog::onColorPicked + Adds to the set of picked colours rather than replacing the previous + pick -- clicking a second, different pixel used to silently discard + the first choice, with no way to work with more than one colour (a + white background *and* a grey border, say) in the same pass. + Skips an exact duplicate rather than adding a second, indistinguishable + swatch for it. + @param color the colour sampled from the source image +*/ +void ImageTransparentColorDialog::onColorPicked(const QColor &color) +{ + if (m_pickedColors.contains(color)) + return; + + m_pickedColors.append(color); + m_okButton->setEnabled(true); + rebuildSwatches(); + updatePreview(); +} + +/** + @brief ImageTransparentColorDialog::removeColor + Removes one colour from the set -- the counterpart onColorPicked() + was missing entirely before: picking the wrong pixel by mistake had + no way to undo except cancelling the whole dialog and starting over. + @param color the colour to remove +*/ +void ImageTransparentColorDialog::removeColor(const QColor &color) +{ + m_pickedColors.removeAll(color); + m_okButton->setEnabled(!m_pickedColors.isEmpty()); + rebuildSwatches(); + updatePreview(); +} + +/** + @brief ImageTransparentColorDialog::rebuildSwatches + Rebuilds the row of picked-colour swatches from scratch against the + current m_pickedColors -- simpler and safer than trying to + incrementally add/remove individual widgets in sync with the list, + given the list only ever changes one colour at a time and is never + large enough for a full rebuild to be a real cost. +*/ +void ImageTransparentColorDialog::rebuildSwatches() +{ + QLayoutItem *item; + while ((item = m_swatchesLayout->takeAt(0)) != nullptr) + { + delete item->widget(); + delete item; + } + + for (const QColor &color : std::as_const(m_pickedColors)) + { + auto *swatch = new QPushButton(this); + swatch->setFixedSize(24, 24); + swatch->setStyleSheet(QStringLiteral("background-color: rgb(%1,%2,%3); border: 1px solid palette(mid);") + .arg(color.red()).arg(color.green()).arg(color.blue())); + swatch->setToolTip(tr("rgb(%1, %2, %3) -- cliquer pour retirer").arg(color.red()).arg(color.green()).arg(color.blue())); + connect(swatch, &QPushButton::clicked, this, [this, color]() { removeColor(color); }); + m_swatchesLayout->addWidget(swatch); + } + + m_hintLabel->setText(m_pickedColors.isEmpty() + ? tr("Cliquez sur l'image pour choisir une couleur") + : tr("Cliquez sur l'image pour ajouter une couleur, ou sur une pastille pour la retirer")); +} + +/** + @brief ImageTransparentColorDialog::onToleranceChanged + @param value the new tolerance, 0-100 +*/ +void ImageTransparentColorDialog::onToleranceChanged(int value) +{ + m_tolerance = value; + if (!m_pickedColors.isEmpty()) + updatePreview(); +} + +/** + @brief ImageTransparentColorDialog::updatePreview + Recomputes the checkerboard-backed preview against the current set + of picked colours and the shared tolerance. Always runs against + m_sourceImage (the original, full-resolution image), not any + already-keyed result -- so adjusting the tolerance, or adding or + removing a colour, re-evaluates every picked colour from scratch + each time rather than compounding successive passes. +*/ +void ImageTransparentColorDialog::updatePreview() +{ + const QImage keyed = applyColorKey(m_sourceImage, m_pickedColors, m_tolerance); + m_previewLabel->setPixmap(onCheckerboard(keyed)); +} + +/** + @brief ImageTransparentColorDialog::resultPixmap + @return the colour-keyed pixmap against every picked colour, or the + original pixmap unchanged if none were ever picked (the Ok button + stays disabled until at least one is, so this is mostly a defensive + fallback). +*/ +QPixmap ImageTransparentColorDialog::resultPixmap() const +{ + if (m_pickedColors.isEmpty()) + return QPixmap::fromImage(m_sourceImage); + return QPixmap::fromImage(applyColorKey(m_sourceImage, m_pickedColors, m_tolerance)); +} + +/** + @brief ImageTransparentColorDialog::applyColorKey + Binary transparency within tolerance, not a smooth falloff: every + pixel within `tolerance` (0-100, mapped onto the maximum possible + RGB distance) of *any* of keyColors becomes fully transparent, + everything else keeps its existing alpha untouched. Squared distance + throughout, avoiding a sqrt per pixel; breaks out of the inner loop + on the first matching colour, since further matches wouldn't change + the outcome. + @param source the image to key + @param keyColors the colours to make transparent + @param tolerance 0 (exact match only) to 100 (everything) + @return the resulting image, always in Format_ARGB32 +*/ +QImage ImageTransparentColorDialog::applyColorKey(const QImage &source, const QList &keyColors, int tolerance) +{ + QImage result = source.convertToFormat(QImage::Format_ARGB32); + if (keyColors.isEmpty()) + return result; + + QVector keys; + keys.reserve(keyColors.size()); + for (const QColor &c : keyColors) + keys.append(c.rgb()); + + const qint64 threshold = qint64(tolerance) * tolerance * 3 * 255 * 255 / (100 * 100); + + for (int y = 0; y < result.height(); ++y) + { + QRgb *line = reinterpret_cast(result.scanLine(y)); + for (int x = 0; x < result.width(); ++x) + { + const QRgb px = line[x]; + for (const QRgb &key : keys) + { + const int dr = qRed(px) - qRed(key), dg = qGreen(px) - qGreen(key), db = qBlue(px) - qBlue(key); + const qint64 distSq = qint64(dr) * dr + qint64(dg) * dg + qint64(db) * db; + if (distSq <= threshold) + { + line[x] = qRgba(qRed(px), qGreen(px), qBlue(px), 0); + break; + } + } + } + } + return result; +} + +/** + @brief ImageTransparentColorDialog::onCheckerboard + Composites `image` over a light/dark checkerboard, the standard + visual convention for showing where an image is transparent -- + without this, a fully keyed-out region would just show whatever + widget background happens to be behind it, easy to misread as "still + opaque, just white" rather than "correctly transparent". + @param image the (possibly partially transparent) image to composite + @return a checkerboard-backed pixmap ready to display +*/ +QPixmap ImageTransparentColorDialog::onCheckerboard(const QImage &image) +{ + const int cell = 8; + QPixmap board(image.size()); + QPainter painter(&board); + for (int y = 0; y < image.height(); y += cell) + { + for (int x = 0; x < image.width(); x += cell) + { + const bool dark = ((x / cell) + (y / cell)) % 2 == 0; + painter.fillRect(x, y, cell, cell, dark ? QColor(200, 200, 200) : QColor(255, 255, 255)); + } + } + painter.drawImage(0, 0, image); + painter.end(); + return board; +} diff --git a/sources/ui/imagetransparentcolordialog.h b/sources/ui/imagetransparentcolordialog.h new file mode 100644 index 000000000..691983057 --- /dev/null +++ b/sources/ui/imagetransparentcolordialog.h @@ -0,0 +1,133 @@ +/* + 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 IMAGE_TRANSPARENT_COLOR_DIALOG_H +#define IMAGE_TRANSPARENT_COLOR_DIALOG_H + +#include +#include +#include +#include +#include + +class QSlider; +class QPushButton; +class QHBoxLayout; +class QMouseEvent; + +/** + @brief The ClickableImageLabel class + Displays an image scaled to fit a reasonable dialog size, but always + samples the picked color from the original, full-resolution image at + the corresponding coordinate -- never from the (possibly smoothly + interpolated, and therefore color-blended) scaled-down pixmap being + displayed, which would make the picked color subtly wrong for + exactly the pixels near an edge where it matters most. +*/ +class ClickableImageLabel : public QLabel +{ + Q_OBJECT + + public: + explicit ClickableImageLabel(const QImage &sourceImage, QWidget *parent = nullptr); + + signals: + void colorPicked(const QColor &color); + + protected: + void mousePressEvent(QMouseEvent *event) override; + + private: + QImage m_source; + qreal m_displayScale = 1.0; +}; + +/** + @brief The ImageTransparentColorDialog class + Lets the user click directly on a preview of the image to sample one + or more colors -- each click adds to the set rather than replacing + the previous pick, shown as a row of removable swatches -- adjust a + shared tolerance, and see a live checkerboard-backed preview of the + result before committing. A self-contained modal dialog rather than + a diagram-level "click the canvas to pick" interaction mode, since + this needs neither undo-during-drag nor coexistence with other + tools; it only ever needs a handful of clicks, evaluated against a + pixmap the caller already has in hand. +*/ +class ImageTransparentColorDialog : public QDialog +{ + Q_OBJECT + + public: + /// @param basePixmap the pristine source to pick colours from -- + /// the caller's responsibility to pass the true original, not + /// an already colour-keyed result, or previously-transparent + /// areas would show as plain background rather than a pickable + /// surface, and re-picking the same colour would be a no-op. + /// @param existingColors colours already keyed out of basePixmap + /// in a previous session, shown as swatches from the start + /// rather than forcing them to be re-picked from scratch. + /// @param existingTolerance the tolerance from that previous + /// session, if any. + explicit ImageTransparentColorDialog(const QPixmap &basePixmap, const QList &existingColors = {}, + int existingTolerance = 10, QWidget *parent = nullptr); + + /// The resulting pixmap: basePixmap unchanged if no colour is + /// picked, colour-keyed against every picked colour otherwise. + QPixmap resultPixmap() const; + /// The final set of picked colours, for the caller to remember + /// across dialog sessions -- may differ from existingColors if + /// any were added or removed. + QList pickedColors() const { return m_pickedColors; } + /// The final tolerance, for the same reason. + int tolerance() const { return m_tolerance; } + + /// Public so DiagramImageItem can re-derive its display pixmap + /// directly (base + crop + these colours) without needing to + /// re-open this dialog every time the crop region changes -- + /// binary transparency within tolerance, not a smooth falloff: + /// every pixel within `tolerance` (0-100, mapped onto the + /// maximum possible RGB distance) of *any* of keyColors becomes + /// fully transparent, everything else keeps its existing alpha + /// untouched. Squared distance throughout, avoiding a sqrt per + /// pixel; breaks out of the inner loop on the first matching + /// colour, since further matches wouldn't change the outcome. + static QImage applyColorKey(const QImage &source, const QList &keyColors, int tolerance); + + private slots: + void onColorPicked(const QColor &color); + void onToleranceChanged(int value); + + private: + void removeColor(const QColor &color); + void rebuildSwatches(); + void updatePreview(); + static QPixmap onCheckerboard(const QImage &image); + + QImage m_sourceImage; + QList m_pickedColors; + int m_tolerance = 10; + + ClickableImageLabel *m_sourceLabel; + QLabel *m_previewLabel; + QHBoxLayout *m_swatchesLayout; + QLabel *m_hintLabel; + QSlider *m_toleranceSlider; + QPushButton *m_okButton; +}; + +#endif // IMAGE_TRANSPARENT_COLOR_DIALOG_H diff --git a/sources/ui/shapegraphicsitempropertieswidget.cpp b/sources/ui/shapegraphicsitempropertieswidget.cpp index 5d4580321..2efed0bb8 100644 --- a/sources/ui/shapegraphicsitempropertieswidget.cpp +++ b/sources/ui/shapegraphicsitempropertieswidget.cpp @@ -73,7 +73,8 @@ void ShapeGraphicsItemPropertiesWidget::setItem(QetShapeItem *shape) } m_shape = shape; - ui->m_close_polygon->setVisible(m_shape->shapeType() == QetShapeItem::Polygon); + ui->m_close_polygon->setVisible(m_shape->shapeType() == QetShapeItem::Polygon + || m_shape->shapeType() == QetShapeItem::Path); ui->m_filling_gb->setHidden(m_shape->shapeType() == QetShapeItem::Line); updateUi(); @@ -164,6 +165,58 @@ QUndoCommand* ShapeGraphicsItemPropertiesWidget::associatedUndo() const { QPropertyUndoCommand *undo = nullptr; + //Geometry + const bool hasGeometry = (m_shape->shapeType() == QetShapeItem::Line + || m_shape->shapeType() == QetShapeItem::Rectangle + || m_shape->shapeType() == QetShapeItem::Ellipse); + if (hasGeometry) + { + if (m_shape->shapeType() == QetShapeItem::Line) + { + const QLineF old_line = m_shape->line(); + QLineF new_line = old_line; + new_line.setLength(ui->m_geom_dim1_dsb->value()); + + if (new_line != old_line) + { + undo = new QPropertyUndoCommand(m_shape, "line", old_line, new_line); + undo->setText(tr("Modifier la longueur d'une ligne")); + } + } + else + { + const QRectF old_rect = m_shape->rect().normalized(); + const bool isEllipse = (m_shape->shapeType() == QetShapeItem::Ellipse); + const qreal new_width = isEllipse ? ui->m_geom_dim1_dsb->value() * 2.0 : ui->m_geom_dim1_dsb->value(); + const qreal new_height = isEllipse ? ui->m_geom_dim2_dsb->value() * 2.0 : ui->m_geom_dim2_dsb->value(); + // Anchored on the shape's own current top-left, not + // re-centered -- matches how the resize handle itself + // behaves (the opposite corner stays put while the + // dragged one moves), so typing a width/height here + // and dragging a handle agree on what "resizing" means. + const QRectF new_rect(old_rect.topLeft(), QSizeF(new_width, new_height)); + + if (new_rect != old_rect) + { + undo = new QPropertyUndoCommand(m_shape, "rect", old_rect, new_rect); + undo->setText(tr("Modifier la taille d'une forme")); + } + } + + const qreal old_angle = m_shape->rotation(); + const qreal new_angle = ui->m_geom_angle_dsb->value(); + if (qAbs(old_angle - new_angle) > 0.05) + { + if (undo) + new QPropertyUndoCommand(m_shape, "rotation", old_angle, new_angle, undo); + else + { + undo = new QPropertyUndoCommand(m_shape, "rotation", old_angle, new_angle); + undo->setText(tr("Modifier l'angle d'une forme")); + } + } + } + QPen old_pen = m_shape->pen(); QPen new_pen = old_pen; @@ -366,6 +419,35 @@ void ShapeGraphicsItemPropertiesWidget::updateUi() if (m_shape) { + //Geometry + const bool hasGeometry = (m_shape->shapeType() == QetShapeItem::Line + || m_shape->shapeType() == QetShapeItem::Rectangle + || m_shape->shapeType() == QetShapeItem::Ellipse); + ui->m_geometry_gb->setVisible(hasGeometry); + if (hasGeometry) + { + ui->m_geom_angle_dsb->setValue(m_shape->rotation()); + + if (m_shape->shapeType() == QetShapeItem::Line) + { + ui->m_geom_dim1_label->setText(tr("Longueur")); + ui->m_geom_dim1_dsb->setValue(m_shape->line().length()); + ui->m_geom_dim2_label->setVisible(false); + ui->m_geom_dim2_dsb->setVisible(false); + } + else + { + const QRectF r = m_shape->rect().normalized(); + const bool isEllipse = (m_shape->shapeType() == QetShapeItem::Ellipse); + ui->m_geom_dim1_label->setText(isEllipse ? tr("Rayon X") : tr("Largeur")); + ui->m_geom_dim2_label->setText(isEllipse ? tr("Rayon Y") : tr("Hauteur")); + ui->m_geom_dim1_dsb->setValue(isEllipse ? r.width() / 2.0 : r.width()); + ui->m_geom_dim2_dsb->setValue(isEllipse ? r.height() / 2.0 : r.height()); + ui->m_geom_dim2_label->setVisible(true); + ui->m_geom_dim2_dsb->setVisible(true); + } + } + //Pen ui->m_style_cb->setCurrentIndex(static_cast(m_shape->pen().style()) - 1); ui->m_size_dsb ->setValue(m_shape->pen().widthF()); @@ -373,7 +455,7 @@ void ShapeGraphicsItemPropertiesWidget::updateUi() ui->m_color_kpb->setColor(m_shape->pen().color()); //Brush - if (m_shape->shapeType() == QetShapeItem::Polygon) + if (m_shape->shapeType() == QetShapeItem::Polygon || m_shape->shapeType() == QetShapeItem::Path) ui->m_filling_gb->setVisible(m_shape->isClosed()); ui->m_brush_style_cb->setCurrentIndex(static_cast(m_shape->brush().style())); @@ -384,6 +466,7 @@ void ShapeGraphicsItemPropertiesWidget::updateUi() } else if (m_shapes_list.size() >= 2) { + ui->m_geometry_gb->setVisible(false); ui->m_close_polygon->setHidden(true); bool same = true; //Pen @@ -477,6 +560,15 @@ void ShapeGraphicsItemPropertiesWidget::setUpEditConnection() if (m_shape || !m_shapes_list.isEmpty()) { + m_edit_connection << connect (ui->m_geom_dim1_dsb, QOverload::of(&QDoubleSpinBox::valueChanged), + this, &ShapeGraphicsItemPropertiesWidget::apply); + + m_edit_connection << connect (ui->m_geom_dim2_dsb, QOverload::of(&QDoubleSpinBox::valueChanged), + this, &ShapeGraphicsItemPropertiesWidget::apply); + + m_edit_connection << connect (ui->m_geom_angle_dsb, QOverload::of(&QDoubleSpinBox::valueChanged), + this, &ShapeGraphicsItemPropertiesWidget::apply); + m_edit_connection << connect (ui->m_style_cb, QOverload::of(&QComboBox::activated), this, &ShapeGraphicsItemPropertiesWidget::apply); @@ -495,14 +587,23 @@ void ShapeGraphicsItemPropertiesWidget::setUpEditConnection() m_edit_connection << connect (ui->m_close_polygon, &QCheckBox::clicked, this, &ShapeGraphicsItemPropertiesWidget::apply); - m_edit_connection << connect (m_shape, &QetShapeItem::penChanged, - this, &ShapeGraphicsItemPropertiesWidget::updateUi); + if (m_shape) + { + m_edit_connection << connect (m_shape, &QetShapeItem::penChanged, + this, &ShapeGraphicsItemPropertiesWidget::updateUi); - m_edit_connection << connect (m_shape, &QetShapeItem::closeChanged, - this, &ShapeGraphicsItemPropertiesWidget::updateUi); + m_edit_connection << connect (m_shape, &QetShapeItem::closeChanged, + this, &ShapeGraphicsItemPropertiesWidget::updateUi); - m_edit_connection << connect (m_shape, &QetShapeItem::brushChanged, - this, &ShapeGraphicsItemPropertiesWidget::updateUi); + m_edit_connection << connect (m_shape, &QetShapeItem::brushChanged, + this, &ShapeGraphicsItemPropertiesWidget::updateUi); + + m_edit_connection << connect (m_shape, &QetShapeItem::geometryChanged, + this, &ShapeGraphicsItemPropertiesWidget::updateUi); + + m_edit_connection << connect (m_shape, &QetShapeItem::transformChanged, + this, &ShapeGraphicsItemPropertiesWidget::updateUi); + } } } diff --git a/sources/ui/shapegraphicsitempropertieswidget.ui b/sources/ui/shapegraphicsitempropertieswidget.ui index 01e9ae4a3..8d46e874f 100644 --- a/sources/ui/shapegraphicsitempropertieswidget.ui +++ b/sources/ui/shapegraphicsitempropertieswidget.ui @@ -14,6 +14,78 @@ Form + + + + Géométrie + + + + + + Largeur + + + + + + + 2 + + + 0.010000000000000 + + + 100000.000000000000000 + + + + + + + Hauteur + + + + + + + 2 + + + 0.010000000000000 + + + 100000.000000000000000 + + + + + + + Angle + + + + + + + 1 + + + -360.000000000000000 + + + 360.000000000000000 + + + ° + + + + + + @@ -230,7 +302,7 @@ - Polygone fermé + Forme fermée @@ -257,6 +329,9 @@ + m_geom_dim1_dsb + m_geom_dim2_dsb + m_geom_angle_dsb m_style_cb m_size_dsb m_color_kpb diff --git a/sources/undocommand/promoteshapecommand.cpp b/sources/undocommand/promoteshapecommand.cpp new file mode 100644 index 000000000..0983d3f64 --- /dev/null +++ b/sources/undocommand/promoteshapecommand.cpp @@ -0,0 +1,43 @@ +/* + 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 "promoteshapecommand.h" +#include "../qetgraphicsitem/qetshapeitem.h" + +PromoteShapeCommand::PromoteShapeCommand( + QetShapeItem *shape, + const QDomElement &priorStateXml, + const QDomElement &newStateXml, + QUndoCommand *parent) : + QUndoCommand(QObject::tr("Transformer %1").arg(shape ? shape->name() : QString()), parent), + m_shape(shape) +{ + m_priorDoc.appendChild(m_priorDoc.importNode(priorStateXml, true)); + m_newDoc.appendChild(m_newDoc.importNode(newStateXml, true)); +} + +void PromoteShapeCommand::undo() +{ + if (m_shape) + m_shape->fromXml(m_priorDoc.documentElement()); +} + +void PromoteShapeCommand::redo() +{ + if (m_shape) + m_shape->fromXml(m_newDoc.documentElement()); +} diff --git a/sources/undocommand/promoteshapecommand.h b/sources/undocommand/promoteshapecommand.h new file mode 100644 index 000000000..1cca6eba6 --- /dev/null +++ b/sources/undocommand/promoteshapecommand.h @@ -0,0 +1,62 @@ +/* + 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 PROMOTESHAPECOMMAND_H +#define PROMOTESHAPECOMMAND_H + +#include +#include +#include + +class QetShapeItem; + +/** + @brief The PromoteShapeCommand class + Used whenever a shape's *identity* changes -- a Rectangle's corner is + dragged independently and it becomes a Polygon, an Arc is explicitly + converted to a free-form Path, etc. + + The saved .qet file never records what a shape used to be; that fact + only needs to survive as long as the rest of the edit history does, so + it lives entirely here, as a full XML snapshot on each side. Undo + restores the exact prior shape -- type, geometry and transform alike -- + by round-tripping through the same fromXml() every other load uses. + Once the file is saved and reopened, the promotion is permanent, same + as any other undo history. +*/ +class PromoteShapeCommand : public QUndoCommand +{ + public: + PromoteShapeCommand( + QetShapeItem *shape, + const QDomElement &priorStateXml, + const QDomElement &newStateXml, + QUndoCommand *parent = nullptr); + + void undo() override; + void redo() override; + + private: + QPointer m_shape; + // Deep-cloned into private documents at construction time: a + // QDomElement is only a reference into whatever QDomDocument it + // came from, and that document (typically a short-lived one built + // just to call toXml()) is not guaranteed to outlive this command. + QDomDocument m_priorDoc, m_newDoc; +}; + +#endif // PROMOTESHAPECOMMAND_H