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
+10
View File
@@ -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
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#232629;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m18.5 4c-.65424 0-1.202197.418077-1.408203 1h-13.0918v1c3.601 0 6.5 2.899 6.5 6.5 0 1.905514-.822115 3.601777-2.121094 4.787109-.245727-.180239-.548776-.287109-.878906-.287109-.831 0-1.5.669-1.5 1.5 0 .831.669 1.5 1.5 1.5.65424 0 1.202197-.418077 1.408203-1h4.091797v1h3v-3h-3v1h-3.912109c1.479147-1.367931 2.412109-3.317097 2.412109-5.5 0-2.788667-1.510668-5.207469-3.757813-6.5h9.349609c.206006.581923.753963 1 1.408203 1 .831 0 1.5-.669 1.5-1.5 0-.831-.669-1.5-1.5-1.5m0 1c.277 0 .5.223.5.5 0 .277-.223.5-.5.5-.06925 0-.135453-.013828-.195312-.039063-.179579-.075703-.304688-.253188-.304688-.460938 0-.06925.013828-.135453.039062-.195313.075704-.179578.253188-.304688.460938-.304688m-11 13c.277 0 .5.223.5.5 0 .277-.223.5-.5.5-.277 0-.5-.223-.5-.5 0-.277.223-.5.5-.5m6.5 0h1v1h-1v-1z" transform="translate(-.99999-.99999)" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -548,6 +548,7 @@
<file>ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-element.svgz</file>
<file>ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-project.svgz</file>
<file>ico/breeze-icons/scalable/mimetypes/small/48x48/application-x-qet-titleblock.svgz</file>
<file>ico/breeze-icons/scalable/apps/hidef/draw-bezier-curves.svg</file>
<file>ico/16x16/object-group.png</file>
<file>ico/mac_icon/elmt.icns</file>
<file>ico/mac_icon/qelectrotech.icns</file>
+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
+59 -5
View File
@@ -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 <QDateTime>
#include <QDebug>
#include <QDir>
#include <QTimer>
#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();
}
}
File diff suppressed because it is too large Load Diff
+134 -2
View File
@@ -19,9 +19,16 @@
#define DIAGRAM_IMAGE_ITEM_H
#include "qetgraphicsitem.h"
#include "shapetransform.h"
#include <QColor>
#include <QList>
#include <QVector>
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<QColor> &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<QColor> 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<QetGraphicsHandlerItem *> m_handler_vector;
QVector<HandleRole> 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
File diff suppressed because it is too large Load Diff
+187 -14
View File
@@ -20,8 +20,11 @@
#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h"
#include "qetgraphicsitem.h"
#include "shapetransform.h"
#include <QPen>
#include <optional>
#include <utility>
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<QPointF> inHandle;
std::optional<QPointF> 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<PathNode> &pathNodes() const {return m_nodes;}
void setPathNodes(const QVector<PathNode> &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<int, qreal> 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<QPointF> 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<QetGraphicsHandlerItem *> 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<HandleRole> m_handleRoles; // parallel to m_handler_vector, one role per handle
QVector<int> 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<PathNode> m_nodes;
QVector<PathNode> 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
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "shapetransform.h"
#include <QtCore/qmath.h>
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));
}
+90
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef SHAPETRANSFORM_H
#define SHAPETRANSFORM_H
#include <QPointF>
#include <QTransform>
/**
@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
+2
View File
@@ -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");
+1
View File
@@ -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;
+287
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "imagecropdialog.h"
#include <QDialogButtonBox>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPushButton>
#include <QVBoxLayout>
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 &center) {
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();
}
+121
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef IMAGE_CROP_DIALOG_H
#define IMAGE_CROP_DIALOG_H
#include <QDialog>
#include <QImage>
#include <QRect>
#include <QWidget>
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
+224 -27
View File
@@ -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);
}
/**
+21 -2
View File
@@ -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
+140 -71
View File
@@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>244</width>
<height>105</height>
<width>280</width>
<height>230</height>
</rect>
</property>
<property name="windowTitle">
@@ -17,47 +17,149 @@
<property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum>
</property>
<item row="2" column="0">
<item row="0" column="0">
<widget class="QLabel" name="label_width">
<property name="text">
<string>Largeur</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QDoubleSpinBox" name="m_width_sb">
<property name="suffix">
<string> %</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>1.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="2" rowspan="2">
<widget class="QToolButton" name="m_lock_ratio_tb">
<property name="toolTip">
<string>Verrouillé : modifier la largeur ou la hauteur ajuste l'autre pour conserver les proportions. Cliquer pour déverrouiller.</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_height">
<property name="text">
<string>Hauteur</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="m_height_sb">
<property name="suffix">
<string> %</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>1.000000000000000</double>
</property>
<property name="maximum">
<double>1000.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="0" colspan="3">
<widget class="QPushButton" name="m_restore_ratio_pb">
<property name="text">
<string>Restaurer les proportions</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_angle">
<property name="text">
<string>Angle</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QDoubleSpinBox" name="m_angle_sb">
<property name="suffix">
<string> °</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>-360.000000000000000</double>
</property>
<property name="maximum">
<double>360.000000000000000</double>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_skew_x">
<property name="text">
<string>Inclinaison X</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QDoubleSpinBox" name="m_skew_x_sb">
<property name="suffix">
<string> °</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>-85.000000000000000</double>
</property>
<property name="maximum">
<double>85.000000000000000</double>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="label_skew_y">
<property name="text">
<string>Inclinaison Y</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="QDoubleSpinBox" name="m_skew_y_sb">
<property name="suffix">
<string> °</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>-85.000000000000000</double>
</property>
<property name="maximum">
<double>85.000000000000000</double>
</property>
</widget>
</item>
<item row="6" column="0" colspan="2">
<widget class="QCheckBox" name="m_lock_pos_cb">
<property name="text">
<string>Verrouiller la position</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Dimension de l'image</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QSlider" name="m_scale_slider">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>400</number>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="m_scale_sb">
<property name="suffix">
<string>%</string>
</property>
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>400</number>
</property>
</widget>
</item>
<item row="3" column="0">
<item row="7" column="0">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
@@ -73,38 +175,5 @@
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>m_scale_slider</sender>
<signal>valueChanged(int)</signal>
<receiver>m_scale_sb</receiver>
<slot>setValue(int)</slot>
<hints>
<hint type="sourcelabel">
<x>81</x>
<y>40</y>
</hint>
<hint type="destinationlabel">
<x>190</x>
<y>40</y>
</hint>
</hints>
</connection>
<connection>
<sender>m_scale_sb</sender>
<signal>valueChanged(int)</signal>
<receiver>m_scale_slider</receiver>
<slot>setValue(int)</slot>
<hints>
<hint type="sourcelabel">
<x>190</x>
<y>40</y>
</hint>
<hint type="destinationlabel">
<x>81</x>
<y>40</y>
</hint>
</hints>
</connection>
</connections>
<connections/>
</ui>
+335
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "imagetransparentcolordialog.h"
#include <QDialogButtonBox>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPushButton>
#include <QSlider>
#include <QVBoxLayout>
#include <QVector>
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<QColor> &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<QColor> &keyColors, int tolerance)
{
QImage result = source.convertToFormat(QImage::Format_ARGB32);
if (keyColors.isEmpty())
return result;
QVector<QRgb> 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<QRgb *>(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;
}
+133
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef IMAGE_TRANSPARENT_COLOR_DIALOG_H
#define IMAGE_TRANSPARENT_COLOR_DIALOG_H
#include <QDialog>
#include <QImage>
#include <QColor>
#include <QLabel>
#include <QList>
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<QColor> &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<QColor> 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<QColor> &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<QColor> 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
@@ -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<int>(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<int>(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<double>::of(&QDoubleSpinBox::valueChanged),
this, &ShapeGraphicsItemPropertiesWidget::apply);
m_edit_connection << connect (ui->m_geom_dim2_dsb, QOverload<double>::of(&QDoubleSpinBox::valueChanged),
this, &ShapeGraphicsItemPropertiesWidget::apply);
m_edit_connection << connect (ui->m_geom_angle_dsb, QOverload<double>::of(&QDoubleSpinBox::valueChanged),
this, &ShapeGraphicsItemPropertiesWidget::apply);
m_edit_connection << connect (ui->m_style_cb, QOverload<int>::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);
}
}
}
@@ -14,6 +14,78 @@
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QGroupBox" name="m_geometry_gb">
<property name="title">
<string>Géométrie</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<widget class="QLabel" name="m_geom_dim1_label">
<property name="text">
<string>Largeur</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QDoubleSpinBox" name="m_geom_dim1_dsb">
<property name="decimals">
<number>2</number>
</property>
<property name="minimum">
<double>0.010000000000000</double>
</property>
<property name="maximum">
<double>100000.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="m_geom_dim2_label">
<property name="text">
<string>Hauteur</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="m_geom_dim2_dsb">
<property name="decimals">
<number>2</number>
</property>
<property name="minimum">
<double>0.010000000000000</double>
</property>
<property name="maximum">
<double>100000.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="m_geom_angle_label">
<property name="text">
<string>Angle</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QDoubleSpinBox" name="m_geom_angle_dsb">
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>-360.000000000000000</double>
</property>
<property name="maximum">
<double>360.000000000000000</double>
</property>
<property name="suffix">
<string> °</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
@@ -230,7 +302,7 @@
<item>
<widget class="QCheckBox" name="m_close_polygon">
<property name="text">
<string>Polygone fermé</string>
<string>Forme fermée</string>
</property>
</widget>
</item>
@@ -257,6 +329,9 @@
</customwidget>
</customwidgets>
<tabstops>
<tabstop>m_geom_dim1_dsb</tabstop>
<tabstop>m_geom_dim2_dsb</tabstop>
<tabstop>m_geom_angle_dsb</tabstop>
<tabstop>m_style_cb</tabstop>
<tabstop>m_size_dsb</tabstop>
<tabstop>m_color_kpb</tabstop>
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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());
}
+62
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef PROMOTESHAPECOMMAND_H
#define PROMOTESHAPECOMMAND_H
#include <QUndoCommand>
#include <QPointer>
#include <QDomElement>
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<QetShapeItem> 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