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.
This commit is contained in:
Andre Rummler
2026-09-05 15:48:28 +02:00
parent 823468826d
commit 6b577ee757
29 changed files with 6784 additions and 433 deletions
+139 -21
View File
@@ -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 <QStatusBar>
#include <QTimer>
/**
@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;
}
@@ -20,6 +20,8 @@
#include "diagrameventinterface.h"
#include <QPointF>
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
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "diagrameventaddpath.h"
#include "../diagram.h"
#include "../lastusedstyle.h"
#include "../qetapp.h"
#include "../qetdiagrameditor.h"
#include "../undocommand/addgraphicsobjectcommand.h"
#include <QGraphicsLineItem>
#include <QGraphicsSceneMouseEvent>
#include <QKeyEvent>
#include <QLineF>
#include <QStatusBar>
#include <QTimer>
/**
@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());
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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<QetShapeItem::PathNode> 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
+271 -5
View File
@@ -19,8 +19,16 @@
#include "../diagram.h"
#include "../lastusedstyle.h"
#include "../qetapp.h"
#include "../qetdiagrameditor.h"
#include "../undocommand/addgraphicsobjectcommand.h"
#include <QGraphicsEllipseItem>
#include <QGuiApplication>
#include <QKeyEvent>
#include <QStatusBar>
#include <QTimer>
/**
@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
@@ -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