mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-20 15:24:14 +02:00
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:
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ¢er) {
|
||||
painter.drawRect(QRect(center.x() - HANDLE_SIZE / 2, center.y() - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE));
|
||||
};
|
||||
drawHandle(m_cropRect.topLeft());
|
||||
drawHandle(m_cropRect.topRight());
|
||||
drawHandle(m_cropRect.bottomLeft());
|
||||
drawHandle(m_cropRect.bottomRight());
|
||||
drawHandle(QPoint(m_cropRect.center().x(), m_cropRect.top()));
|
||||
drawHandle(QPoint(m_cropRect.center().x(), m_cropRect.bottom()));
|
||||
drawHandle(QPoint(m_cropRect.left(), m_cropRect.center().y()));
|
||||
drawHandle(QPoint(m_cropRect.right(), m_cropRect.center().y()));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ImageCropDialog::ImageCropDialog
|
||||
@param pixmap the image to crop
|
||||
@param existingCropRect a crop rectangle remembered from a previous
|
||||
session, or an empty QRect if there isn't one yet
|
||||
@param parent
|
||||
*/
|
||||
ImageCropDialog::ImageCropDialog(const QPixmap &pixmap, const QRect &existingCropRect, QWidget *parent) :
|
||||
QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Rogner l'image"));
|
||||
|
||||
m_cropArea = new CropAreaWidget(pixmap.toImage(), existingCropRect, this);
|
||||
|
||||
auto *hint = new QLabel(tr("Faites glisser les poignées, ou l'intérieur du cadre, pour ajuster la zone à conserver."), this);
|
||||
hint->setWordWrap(true);
|
||||
|
||||
auto *resetButton = new QPushButton(tr("Réinitialiser"), this);
|
||||
resetButton->setToolTip(tr("Revenir à l'image complète, sans rognage"));
|
||||
connect(resetButton, &QPushButton::clicked, m_cropArea, &CropAreaWidget::resetToFullImage);
|
||||
|
||||
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
buttons->addButton(resetButton, QDialogButtonBox::ResetRole);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(hint);
|
||||
layout->addWidget(m_cropArea, 0, Qt::AlignHCenter);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ImageCropDialog::cropRect
|
||||
@return the chosen crop rectangle, in the original pixmap's own coordinates
|
||||
*/
|
||||
QRect ImageCropDialog::cropRect() const
|
||||
{
|
||||
return m_cropArea->cropRect();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user