Compare commits
15 Commits
b98d41357a
...
82026d8f7c
| Author | SHA1 | Date | |
|---|---|---|---|
| 82026d8f7c | |||
| 1793dcb9a8 | |||
| cc7aaf3538 | |||
| a6285c7ef7 | |||
| f87381a910 | |||
| bd5cff4211 | |||
| 296f142036 | |||
| 4d991d0419 | |||
| a85bd6a49b | |||
| ef8e41a5c3 | |||
| 6f80da66d5 | |||
| a065867502 | |||
| 1e1039dd32 | |||
| e410896f5d | |||
| 10bf0411bb |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
<g transform="translate(1,1)">
|
||||
<g class="ColorScheme-Text" fill="currentColor">
|
||||
<path d="m6 14c0 2.77 2.23 5 5 5s5-2.23 5-5z" fill-rule="evenodd" opacity=".66"/>
|
||||
<path d="m7.482 10c-.34.586-.65 1.148-.886 1.676a4.958 4.958 0 0 0 -.596 2.324h10c0-.845-.228-1.627-.596-2.324a18.518 18.518 0 0 0 -.886-1.676z" fill-rule="evenodd" opacity=".33"/>
|
||||
<path d="m11 3c-.973 3.405-3.354 6.332-4.404 8.676a4.958 4.958 0 0 0 -.596 2.324c0 2.77 2.23 5 5 5s5-2.23 5-5c0-.845-.228-1.627-.596-2.324-1.05-2.344-3.431-5.271-4.404-8.676zm0 2.393c1.194 2.668 2.747 5.027 3.492 6.691l.012.03.016.029a3.94 3.94 0 0 1 .48 1.857c0 2.233-1.767 4-4 4a3.97 3.97 0 0 1 -4-4c0-.665.177-1.283.48-1.857l.016-.03.012-.029c.745-1.664 2.298-4.023 3.492-6.691z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 983 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 4 2 L 4 3 L 5 3 L 5 2 L 4 2 z M 11 2 L 11 3 L 12 3 L 12 2 L 11 2 z M 2 4 L 2 12 L 7 12 L 7 10 L 9 10 L 9 12 L 14 12 L 14 4 L 9 4 L 9 6 L 7 6 L 7 4 L 2 4 z M 3 5 L 6 5 L 6 11 L 3 11 L 3 5 z M 10 5 L 13 5 L 13 11 L 10 11 L 10 5 z M 11 6 L 11 10 L 12 10 L 12 6 L 11 6 z M 7 7 L 9 7 L 9 9 L 7 9 L 7 7 z M 4 13 L 4 14 L 5 14 L 5 13 L 4 13 z M 11 13 L 11 14 L 12 14 L 12 13 L 11 13 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#232629;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 4 2 L 4 7 L 6 7 L 6 9 L 4 9 L 4 14 L 12 14 L 12 9 L 10 9 L 10 7 L 12 7 L 12 2 L 4 2 z M 5 3 L 11 3 L 11 6 L 5 6 L 5 3 z M 2 4 L 2 5 L 3 5 L 3 4 L 2 4 z M 13 4 L 13 5 L 14 5 L 14 4 L 13 4 z M 7 7 L 9 7 L 9 9 L 7 9 L 7 7 z M 5 10 L 11 10 L 11 13 L 5 13 L 5 10 z M 2 11 L 2 12 L 3 12 L 3 11 L 2 11 z M 6 11 L 6 12 L 10 12 L 10 11 L 6 11 z M 13 11 L 13 12 L 14 12 L 14 11 L 13 11 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
@@ -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="M 3 3 L 3 5 L 3 6 L 6 6 L 6 5 L 6 3 L 4 3 L 3 3 z M 16 3 L 16 4 L 16 5 L 16 6 L 17 6 L 18 6 L 19 6 L 19 5 L 19 4 L 19 3 L 16 3 z M 4 4 L 5 4 L 5 5 L 4 5 L 4 4 z M 17 4 L 18 4 L 18 5 L 17 5 L 17 4 z M 7 5 L 7 6 L 9 6 L 9 5 L 7 5 z M 10 5 L 10 6 L 12 6 L 12 5 L 10 5 z M 13 5 L 13 6 L 15 6 L 15 5 L 13 5 z M 5 7 L 5 9 L 6 9 L 6 7 L 5 7 z M 16 7 L 16 9 L 17 9 L 17 7 L 16 7 z M 5 10 L 5 12 L 6 12 L 6 10 L 5 10 z M 16 10 L 16 12 L 17 12 L 17 10 L 16 10 z M 5 13 L 5 15 L 6 15 L 6 13 L 5 13 z M 16 13 L 16 15 L 17 15 L 17 13 L 16 13 z M 3 16 L 3 17 L 3 19 L 4 19 L 5 19 L 6 19 L 6 16 L 3 16 z M 7 16 L 7 17 L 9 17 L 9 16 L 7 16 z M 10 16 L 10 17 L 12 17 L 12 16 L 10 16 z M 13 16 L 13 17 L 15 17 L 15 16 L 13 16 z M 16 16 L 16 17 L 16 18 L 16 19 L 17 19 L 18 19 L 19 19 L 19 18 L 19 17 L 19 16 L 17 16 L 16 16 z M 4 17 L 5 17 L 5 18 L 4 18 L 4 17 z M 17 17 L 18 17 L 18 18 L 17 18 L 17 17 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -549,6 +549,13 @@
|
||||
<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/breeze-icons/scalable/apps/hidef/transform-crop.svg</file>
|
||||
<file>ico/breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg</file>
|
||||
<file>ico/breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg</file>
|
||||
<file>ico/breeze-icons/scalable/apps/hidef/edit-opacity.svg</file>
|
||||
<file>ico/generated/rect-to-polyline.svg</file>
|
||||
<file>ico/generated/rect-to-bezier.svg</file>
|
||||
<file>ico/generated/ellipse-to-bezier.svg</file>
|
||||
<file>ico/16x16/object-group.png</file>
|
||||
<file>ico/mac_icon/elmt.icns</file>
|
||||
<file>ico/mac_icon/qelectrotech.icns</file>
|
||||
|
||||
@@ -128,6 +128,18 @@ bool QPropertyUndoCommand::mergeWith(const QUndoCommand *other)
|
||||
QPropertyUndoCommand const *undo = static_cast<const QPropertyUndoCommand *>(other);
|
||||
if (m_object != undo->m_object
|
||||
|| m_property_name != undo->m_property_name) return false;
|
||||
// Same object and property name alone isn't enough: two entirely
|
||||
// separate, deliberate actions (say, cropping an image and then
|
||||
// mirroring it) both go through the same "pixmap" property and
|
||||
// would otherwise silently coalesce into one undo entry, carrying
|
||||
// only the first action's label -- the second vanishes from the
|
||||
// undo list with no way to undo just it. Legitimate merging (a
|
||||
// slider or spinbox pushing one command per tick while being
|
||||
// dragged, e.g. ArcEditor's angle editors) always reuses the exact
|
||||
// same text() across the whole sequence, so requiring a match here
|
||||
// keeps that working unchanged while refusing to merge anything
|
||||
// that isn't actually a continuation of the same action.
|
||||
if (text() != other->text()) return false;
|
||||
m_new_value = undo->m_new_value;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -40,30 +40,13 @@
|
||||
#include "undocommand/addelementtextcommand.h"
|
||||
#include "qetinformation.h"
|
||||
#include "qetproject.h"
|
||||
#include "diagramsortkeys.h"
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <math.h>
|
||||
|
||||
namespace {
|
||||
/// Format a coordinate as a string that sorts the same way the number
|
||||
/// does. Plain fixed-precision formatting ("%.4f") does NOT do this --
|
||||
/// e.g. "15.0000" sorts before "5.0000" as text even though 15 > 5 --
|
||||
/// so shift into a non-negative range and zero-pad to a fixed width
|
||||
/// before comparing.
|
||||
QString coordinateKey(double v)
|
||||
{
|
||||
//Diagram coordinates are nowhere near this range; the offset and
|
||||
//width just need to be big enough that shifted values are always
|
||||
//non-negative and always the same digit count.
|
||||
constexpr double offset = 1e9;
|
||||
qint64 scaled = qint64(qRound64((v + offset) * 10000.0));
|
||||
return QStringLiteral("%1").arg(scaled, 20, 10, QLatin1Char('0'));
|
||||
}
|
||||
|
||||
QString positionKey(const QPointF &pos)
|
||||
{
|
||||
return coordinateKey(pos.x()) + QLatin1Char('|') + coordinateKey(pos.y());
|
||||
}
|
||||
using DiagramSortKeys::positionKey;
|
||||
|
||||
/// Sort key for Diagram::toXml()'s <elements> block: the element's own
|
||||
/// diagram-local position, exactly what it's already saved as (x/y),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
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 DIAGRAMSORTKEYS_H
|
||||
#define DIAGRAMSORTKEYS_H
|
||||
|
||||
#include <QPointF>
|
||||
#include <QString>
|
||||
#include <QtGlobal>
|
||||
|
||||
/// Sort-key helpers used by Diagram::toXml() to serialize elements and
|
||||
/// conductors in a deterministic order. Pulled into their own header
|
||||
/// (instead of an anonymous namespace in diagram.cpp) so they can be
|
||||
/// unit-tested directly -- see tests/qttest/tst_diagramsortkeys.cpp.
|
||||
namespace DiagramSortKeys {
|
||||
|
||||
/// Format a coordinate as a string that sorts the same way the number
|
||||
/// does. Plain fixed-precision formatting ("%.4f") does NOT do this --
|
||||
/// e.g. "15.0000" sorts before "5.0000" as text even though 15 > 5 --
|
||||
/// so shift into a non-negative range and zero-pad to a fixed width
|
||||
/// before comparing.
|
||||
inline QString coordinateKey(double v)
|
||||
{
|
||||
//Diagram coordinates are nowhere near this range; the offset and
|
||||
//width just need to be big enough that shifted values are always
|
||||
//non-negative and always the same digit count.
|
||||
constexpr double offset = 1e9;
|
||||
qint64 scaled = qint64(qRound64((v + offset) * 10000.0));
|
||||
return QStringLiteral("%1").arg(scaled, 20, 10, QLatin1Char('0'));
|
||||
}
|
||||
|
||||
inline QString positionKey(const QPointF &pos)
|
||||
{
|
||||
return coordinateKey(pos.x()) + QLatin1Char('|') + coordinateKey(pos.y());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -200,18 +200,35 @@ void DxfPaintEngine::drawTextItem(const QPointF &p, const QTextItem &textItem)
|
||||
|
||||
/**
|
||||
@brief DxfPaintEngine::drawPixmap
|
||||
Not implemented in v1 - out of scope per the design note in the
|
||||
header (CrossRefItem, the only item exported through this engine so
|
||||
far, never draws a pixmap). qWarning() rather than a hard failure, so
|
||||
an item that does call this in the future degrades to "one entity
|
||||
missing" instead of crashing the whole export.
|
||||
No DXF dialect this old (AC1006, AutoCAD R10 from 1988) has any
|
||||
raster image representation at all -- IMAGE/IMAGEDEF wasn't
|
||||
introduced until R2000, over a decade later, and even there the
|
||||
pixels are never embedded, only referenced by external file path.
|
||||
Actually supporting images means upgrading the DXF version target
|
||||
and managing a second file alongside the DXF; until then, this
|
||||
draws a placeholder rectangle outline (the item's own destination
|
||||
rect, mapped through m_world_transform exactly like drawRects()
|
||||
does) instead of silently dropping the item -- so its position,
|
||||
size, rotation, and skew all survive the export even though the
|
||||
picture itself can't yet. A fixed, plain pen, not m_pen: an image
|
||||
item has no meaningful pen of its own for this engine to have
|
||||
picked up from a prior updateState().
|
||||
*/
|
||||
void DxfPaintEngine::drawPixmap(const QRectF &r, const QPixmap &pm, const QRectF &sr)
|
||||
{
|
||||
Q_UNUSED(r)
|
||||
Q_UNUSED(pm)
|
||||
Q_UNUSED(sr)
|
||||
qWarning() << "DxfPaintEngine::drawPixmap: not supported, entity skipped";
|
||||
|
||||
QPolygonF corners;
|
||||
corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
|
||||
|
||||
QPolygonF mapped;
|
||||
mapped.reserve(corners.size() + 1);
|
||||
for (const QPointF &p : corners)
|
||||
mapped << toDxf(p);
|
||||
mapped << mapped.first();
|
||||
|
||||
Createdxf::drawPolyline(m_filepath, mapped, Createdxf::dxfColor(QPen(Qt::black)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,8 +278,21 @@ int DxfPaintDevice::metric(PaintDeviceMetric metric) const
|
||||
case PdmDepth:
|
||||
return 24;
|
||||
case PdmDevicePixelRatio:
|
||||
case PdmDevicePixelRatioScaled:
|
||||
return 1;
|
||||
case PdmDevicePixelRatioScaled:
|
||||
// Not just 1: Qt's own convention (see QPaintDevice's docs)
|
||||
// is that this metric equals PdmDevicePixelRatio scaled by
|
||||
// devicePixelRatioFScale() -- a large constant (65536)
|
||||
// used internally for sub-integer precision. Returning the
|
||||
// plain, unscaled ratio here (as this used to) meant
|
||||
// QPainter's own transform bookkeeping divided by it
|
||||
// expecting the scaled value, silently shrinking every
|
||||
// drawPixmap() destination rect by a factor of 65536 --
|
||||
// this is what was actually behind the placeholder
|
||||
// rectangle rendering at a barely-visible fraction of its
|
||||
// real size instead of the size the item's own
|
||||
// sceneTransform() correctly specified.
|
||||
return int(1 * QPaintDevice::devicePixelRatioFScale());
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -56,11 +56,18 @@
|
||||
- fillPath -> same outline-only handling as drawRects; no HATCH
|
||||
support in v1 (see design note in the PR)
|
||||
|
||||
Anything outside this list (images, gradients, etc.) is intentionally
|
||||
unimplemented and asserts in debug builds rather than silently
|
||||
producing an incomplete drawing - callers should know immediately if
|
||||
an item they're exporting uses something this engine doesn't cover
|
||||
yet, rather than getting a DXF file quietly missing content.
|
||||
- drawPixmap -> no raster image entity exists in this DXF
|
||||
dialect at all (see drawPixmap()'s own
|
||||
comment) -- draws a placeholder rectangle
|
||||
outline instead, preserving the item's
|
||||
position/size/rotation/skew even though the
|
||||
picture itself can't be included yet
|
||||
|
||||
Anything else outside this list (gradients, etc.) is intentionally
|
||||
unimplemented and logs a warning rather than silently producing an
|
||||
incomplete drawing - callers should know immediately if an item
|
||||
they're exporting uses something this engine doesn't cover yet,
|
||||
rather than getting a DXF file quietly missing content.
|
||||
*/
|
||||
class DxfPaintEngine : public QPaintEngine
|
||||
{
|
||||
|
||||
@@ -744,6 +744,25 @@ void ExportDialog::generateDxf(
|
||||
painter.end();
|
||||
}
|
||||
|
||||
//Draw images -- collected above (list_images) but never actually
|
||||
//drawn until now, an existing gap this reuses the same paint()
|
||||
//-replay approach to fix: DiagramImageItem::paint() has no
|
||||
//viewport-dependent logic (unlike CrossRefItem, which needs its own
|
||||
//paintForExport() for that reason), so it's called directly with a
|
||||
//default QStyleOptionGraphicsItem rather than needing an export-
|
||||
//specific variant of its own. DxfPaintEngine::drawPixmap() is what
|
||||
//actually turns the drawPixmap() call inside paint() into a
|
||||
//placeholder outline, since this DXF dialect has no raster image
|
||||
//entity to draw instead.
|
||||
for (DiagramImageItem *image : std::as_const(list_images))
|
||||
{
|
||||
DxfPaintDevice dxf_device(file_path);
|
||||
QPainter painter(&dxf_device);
|
||||
painter.setWorldTransform(image->sceneTransform());
|
||||
image->paintForExport(&painter);
|
||||
painter.end();
|
||||
}
|
||||
|
||||
Createdxf::dxfEnd(file_path);
|
||||
|
||||
saveReloadDiagramParameters(diagram, false);
|
||||
@@ -818,6 +837,42 @@ void ExportDialog::slot_export()
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn once, up front, rather than per-diagram: this DXF dialect
|
||||
// (AC1006, AutoCAD R10) has no raster image representation at all
|
||||
// (IMAGE/IMAGEDEF wasn't introduced until R2000, over a decade
|
||||
// later, and even there the picture is never embedded, only
|
||||
// referenced by external file path) -- so any image ends up as a
|
||||
// placeholder rectangle outline instead (see DxfPaintEngine::
|
||||
// drawPixmap()), with its position/size/rotation/skew preserved but
|
||||
// not its actual content.
|
||||
if (epw -> exportProperties().format.compare(QLatin1String("dxf"), Qt::CaseInsensitive) == 0)
|
||||
{
|
||||
bool any_images = false;
|
||||
for (ExportDiagramLine *diagram_line : std::as_const(diagrams_to_export))
|
||||
{
|
||||
for (QGraphicsItem *item : diagram_line->diagram->items())
|
||||
{
|
||||
if (qgraphicsitem_cast<DiagramImageItem *>(item)) {
|
||||
any_images = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (any_images) break;
|
||||
}
|
||||
|
||||
if (any_images)
|
||||
{
|
||||
QET::QetMessageBox::warning(
|
||||
this,
|
||||
tr("Images non incluses dans l'export DXF", "message box title"),
|
||||
tr("Le format DXF utilisé ici (AC1006) ne permet pas d'inclure d'image. "
|
||||
"Les images seront représentées uniquement par un rectangle de contour "
|
||||
"(position, taille, rotation et inclinaison conservées), sans le contenu de l'image.",
|
||||
"message box content")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// exporte chaque schema a exporter
|
||||
foreach(ExportDiagramLine *diagram_line, diagrams_to_export) {
|
||||
exportDiagram(diagram_line);
|
||||
|
||||
@@ -297,11 +297,19 @@ QString QET::ElementsAndConductorsSentence(
|
||||
|
||||
if (images_count) {
|
||||
if (!text.isEmpty()) text += ", ";
|
||||
text += QObject::tr(
|
||||
"%n image(s)",
|
||||
"part of a sentence listing the content of a diagram",
|
||||
images_count
|
||||
);
|
||||
// Qt's %n only selects a grammatical singular/plural form (the
|
||||
// "(s)" convention used by every other count here) -- it never
|
||||
// spells the number out as a word, so getting "une image"
|
||||
// instead of the literal "1 image" for the single-item case
|
||||
// means handling that count outside %n entirely, with its own
|
||||
// fixed string.
|
||||
text += images_count == 1
|
||||
? QObject::tr("une image", "part of a sentence listing the content of a diagram")
|
||||
: QObject::tr(
|
||||
"%n images",
|
||||
"part of a sentence listing the content of a diagram",
|
||||
images_count
|
||||
);
|
||||
}
|
||||
|
||||
if (shapes_count) {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "../qet.h"
|
||||
#include "../qetapp.h"
|
||||
#include "../qetdiagrameditor.h"
|
||||
#include "../qeticons.h"
|
||||
#include "../ui/imagepropertieswidget.h"
|
||||
#include "../ui/imagecropdialog.h"
|
||||
#include "../ui/imagetransparentcolordialog.h"
|
||||
@@ -31,10 +32,14 @@
|
||||
#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QBuffer>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
#include <QGraphicsSceneContextMenuEvent>
|
||||
#include <QImageWriter>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QTextStream>
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::DiagramImageItem
|
||||
@@ -811,11 +816,16 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
|
||||
else
|
||||
undo = new QPropertyUndoCommand(this, "scaleFactorY", m_original_transform.scaleY, m_transform.scaleY);
|
||||
}
|
||||
if (undo)
|
||||
undo->setText(tr("Redimensionner une image"));
|
||||
break;
|
||||
|
||||
case HandleRole::Rotate:
|
||||
if (!qFuzzyCompare(m_transform.rotation, m_original_transform.rotation))
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "rotationAngle", m_original_transform.rotation, m_transform.rotation);
|
||||
undo->setText(tr("Faire pivoter une image"));
|
||||
}
|
||||
break;
|
||||
|
||||
case HandleRole::SkewEdge:
|
||||
@@ -823,6 +833,8 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
|
||||
undo = new QPropertyUndoCommand(this, "skewX", m_original_transform.skewX, m_transform.skewX);
|
||||
else if (!qFuzzyCompare(m_transform.skewY, m_original_transform.skewY))
|
||||
undo = new QPropertyUndoCommand(this, "skewY", m_original_transform.skewY, m_transform.skewY);
|
||||
if (undo)
|
||||
undo->setText(tr("Incliner une image"));
|
||||
break;
|
||||
|
||||
case HandleRole::Pivot:
|
||||
@@ -837,8 +849,28 @@ void DiagramImageItem::handlerMouseReleaseEvent(int index)
|
||||
|
||||
if (undo)
|
||||
{
|
||||
// Defensive fallback only -- every role above now sets its
|
||||
// own, distinct label directly (Resize/Rotate/SkewEdge used
|
||||
// to all fall through to this same generic text, making the
|
||||
// undo list unable to tell three completely different edits
|
||||
// apart); this only still matters if some future role is
|
||||
// ever added without setting one of its own.
|
||||
if (undo->text().isEmpty())
|
||||
undo->setText(tr("Modifier une image"));
|
||||
|
||||
// Every push here is one complete, finished gesture (press,
|
||||
// drag, release) -- never a continuation of an earlier one.
|
||||
// QPropertyUndoCommand::mergeWith() already treats any
|
||||
// command with children as never mergeable; a dummy child
|
||||
// guarantees that here regardless of which role produced
|
||||
// undo. Without this, a single-property change (Rotate and
|
||||
// SkewEdge always are; Resize sometimes is, when only one
|
||||
// axis actually changed) would silently coalesce into
|
||||
// whatever identically-labelled edit came right before it
|
||||
// -- even after a deselect/reselect proved they were two
|
||||
// separate actions, since the shared label alone is
|
||||
// otherwise indistinguishable from a genuine continuation.
|
||||
new QUndoCommand(undo);
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
}
|
||||
@@ -1029,6 +1061,177 @@ void DiagramImageItem::restoreAspectRatio()
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::saveImageAs
|
||||
Saves the currently displayed pixmap (crop and colour-keyed
|
||||
transparency already applied -- what the item actually looks like
|
||||
on the diagram, not the pristine original) to an arbitrary file on
|
||||
disk.
|
||||
*/
|
||||
void DiagramImageItem::saveImageAs()
|
||||
{
|
||||
saveImagePixmapAs(pixmap_, tr("Enregistrer l'image sous..."), !m_transparent_colors.isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::saveOriginalImageAs
|
||||
Saves m_base_pixmap -- the true, pristine original, before any crop
|
||||
or colour-keyed transparency -- rather than the item's current,
|
||||
possibly-cropped-and-keyed display pixmap. The only way to recover
|
||||
the un-cropped, un-keyed source once either of those has actually
|
||||
been applied, short of undoing every edit back to the point it was
|
||||
first inserted or replaced.
|
||||
*/
|
||||
void DiagramImageItem::saveOriginalImageAs()
|
||||
{
|
||||
saveImagePixmapAs(m_base_pixmap, tr("Enregistrer l'image d'origine sous..."), false);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::saveImagePixmapAs
|
||||
Shared by saveImageAs() and saveOriginalImageAs(): prompts for a
|
||||
destination, resolves whichever format was actually intended, warns
|
||||
before silently dropping transparency, and writes the file. A
|
||||
read-only export, not an edit: doesn't touch diagram()'s undo stack,
|
||||
and works even on a read-only diagram, unlike every other action in
|
||||
this item's context menu.
|
||||
@param pixmap the pixmap to save -- pixmap_ or m_base_pixmap
|
||||
@param dialogTitle distinguishes the two callers in the save dialog's own title bar
|
||||
@param hasTransparency whether `pixmap` has colour-keyed transparency worth warning about losing (never true for the pristine original, which predates any such keying)
|
||||
*/
|
||||
void DiagramImageItem::saveImagePixmapAs(const QPixmap &pixmap, const QString &dialogTitle, bool hasTransparency)
|
||||
{
|
||||
QWidget *parentWidget = (diagram() && !diagram()->views().isEmpty()) ? diagram()->views().first() : nullptr;
|
||||
|
||||
// Filter text -> extension. Built once and used both to construct
|
||||
// the dialog's filter list and, below, to resolve whichever one the
|
||||
// person actually had selected -- rather than only ever falling
|
||||
// back to a single hardcoded format regardless of their choice,
|
||||
// which is what this used to do (getSaveFileName() doesn't reliably
|
||||
// auto-append the selected filter's extension on every platform,
|
||||
// and the previous version's fallback ignored the selected filter
|
||||
// entirely, defaulting to PNG even when JPEG or BMP had been
|
||||
// explicitly chosen).
|
||||
const QList<QPair<QString, QString>> filters = {
|
||||
{tr("Image PNG (*.png)"), QStringLiteral("png")},
|
||||
{tr("Image JPEG (*.jpg *.jpeg)"), QStringLiteral("jpg")},
|
||||
{tr("Image BMP (*.bmp)"), QStringLiteral("bmp")},
|
||||
// SVG here always means a raster image wrapped in an SVG
|
||||
// container (an <image> element embedding this same pixmap as
|
||||
// base64 PNG), never a true vector export -- this item only
|
||||
// ever holds raster data, even when originally inserted from an
|
||||
// SVG file, since that file was rasterized once at import time
|
||||
// and its vector information is already gone by the time this
|
||||
// runs.
|
||||
{tr("Image SVG (*.svg)"), QStringLiteral("svg")},
|
||||
};
|
||||
QStringList filterStrings;
|
||||
for (const auto &f : filters)
|
||||
filterStrings << f.first;
|
||||
filterStrings << tr("Tous les fichiers (*)");
|
||||
|
||||
QString selectedFilter;
|
||||
QString path = QFileDialog::getSaveFileName(
|
||||
parentWidget, dialogTitle, QString(), filterStrings.join(QLatin1String(";;")), &selectedFilter);
|
||||
if (path.isEmpty())
|
||||
return;
|
||||
|
||||
// Resolve the target format: prefer an extension already present
|
||||
// and actually writable, otherwise fall back to whichever filter
|
||||
// was selected in the dialog (not a fixed default), so the format
|
||||
// picked in the dropdown is the one that's actually honoured even
|
||||
// when the typed name carries no extension of its own.
|
||||
QString suffix = QFileInfo(path).suffix().toLower();
|
||||
const bool suffixIsSvg = (suffix == QLatin1String("svg"));
|
||||
const bool suffixIsWritableRaster = QImageWriter::supportedImageFormats().contains(suffix.toUtf8());
|
||||
if (!suffixIsSvg && !suffixIsWritableRaster)
|
||||
{
|
||||
QString fallbackExt = QStringLiteral("png");
|
||||
for (const auto &f : filters)
|
||||
if (f.first == selectedFilter) { fallbackExt = f.second; break; }
|
||||
path += QLatin1Char('.') + fallbackExt;
|
||||
suffix = fallbackExt;
|
||||
}
|
||||
|
||||
// JPEG and BMP have no usable alpha channel here -- Qt's writers for
|
||||
// both silently drop it. That alone wouldn't be too bad (the
|
||||
// warning already tells the person transparency won't survive),
|
||||
// except pixmap_ itself has already lost the true colour
|
||||
// information wherever it's transparent by this point: QPixmap
|
||||
// stores its data premultiplied internally, which zeroes out RGB
|
||||
// at alpha=0 the moment applyColorKey()'s QImage result gets
|
||||
// wrapped into a QPixmap -- confirmed directly, not assumed
|
||||
// (QPixmap::fromImage() on a white, alpha=0 pixel reliably comes
|
||||
// back black on toImage()). Saving pixmap_ as-is to a format with
|
||||
// no alpha channel doesn't fall back to the original picture, it
|
||||
// reveals that already-lost black. The fix below substitutes the
|
||||
// cropped base pixmap for the save in this specific case -- it has
|
||||
// the same, correct RGB values everywhere applyColorKey() left a
|
||||
// pixel opaque (colour-keying only ever changes alpha, never RGB),
|
||||
// so it's an exact reproduction of what the image looked like
|
||||
// before any colour was ever keyed out, not an approximation.
|
||||
const bool formatPreservesAlpha = (suffix == QLatin1String("png") || suffix == QLatin1String("svg"));
|
||||
QPixmap toSave = pixmap;
|
||||
if (hasTransparency && !formatPreservesAlpha)
|
||||
{
|
||||
if (QMessageBox::warning(parentWidget,
|
||||
tr("Transparence non conservée"),
|
||||
tr("Ce format ne prend pas en charge la transparence : l'image sera enregistrée "
|
||||
"telle qu'elle était avant l'application de la couleur transparente. Continuer ?"),
|
||||
QMessageBox::Yes | QMessageBox::Cancel) != QMessageBox::Yes)
|
||||
return;
|
||||
|
||||
toSave = m_base_pixmap.copy(m_crop_rect);
|
||||
}
|
||||
|
||||
bool ok;
|
||||
if (suffix == QLatin1String("svg"))
|
||||
ok = writeRasterAsSvg(toSave, path);
|
||||
else
|
||||
ok = toSave.save(path);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
QMessageBox::warning(parentWidget,
|
||||
tr("Échec de l'enregistrement"),
|
||||
tr("Impossible d'enregistrer l'image à cet emplacement."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::writeRasterAsSvg
|
||||
Wraps `pixmap` as a base64-embedded PNG inside a minimal, valid SVG
|
||||
document -- the closest this can honestly offer to "save as SVG"
|
||||
given this item only ever holds raster data (see the note in
|
||||
saveImagePixmapAs()). Opens in any SVG viewer at the pixmap's own
|
||||
pixel size, but is not, and cannot be, a vector re-export.
|
||||
@param pixmap the raster image to embed
|
||||
@param path destination file path
|
||||
@return whether the file was written successfully
|
||||
*/
|
||||
bool DiagramImageItem::writeRasterAsSvg(const QPixmap &pixmap, const QString &path)
|
||||
{
|
||||
QByteArray pngData;
|
||||
QBuffer buffer(&pngData);
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
if (!pixmap.save(&buffer, "PNG"))
|
||||
return false;
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
|
||||
return false;
|
||||
|
||||
QTextStream out(&file);
|
||||
out << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
<< "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" "
|
||||
<< "width=\"" << pixmap.width() << "\" height=\"" << pixmap.height() << "\" "
|
||||
<< "viewBox=\"0 0 " << pixmap.width() << ' ' << pixmap.height() << "\">\n"
|
||||
<< " <image width=\"" << pixmap.width() << "\" height=\"" << pixmap.height() << "\" "
|
||||
<< "xlink:href=\"data:image/png;base64," << QString::fromLatin1(pngData.toBase64()) << "\"/>\n"
|
||||
<< "</svg>\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramImageItem::itemChange
|
||||
*/
|
||||
@@ -1382,23 +1585,33 @@ void DiagramImageItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
|
||||
QAction *replace = menu.data()->addAction(tr("Remplacer l'image..."));
|
||||
connect(replace, &QAction::triggered, this, &DiagramImageItem::replaceImage);
|
||||
|
||||
QAction *saveAs = menu.data()->addAction(tr("Enregistrer l'image sous..."));
|
||||
connect(saveAs, &QAction::triggered, this, &DiagramImageItem::saveImageAs);
|
||||
|
||||
QAction *saveOriginalAs = menu.data()->addAction(tr("Enregistrer l'image d'origine sous..."));
|
||||
connect(saveOriginalAs, &QAction::triggered, this, &DiagramImageItem::saveOriginalImageAs);
|
||||
|
||||
QAction *transparentColor = menu.data()->addAction(tr("Couleur transparente..."));
|
||||
transparentColor->setIcon(QET::Icons::EditOpacity);
|
||||
connect(transparentColor, &QAction::triggered, this, &DiagramImageItem::setTransparentColor);
|
||||
|
||||
QAction *cropAction = menu.data()->addAction(tr("Rogner..."));
|
||||
cropAction->setIcon(QET::Icons::TransformCrop);
|
||||
connect(cropAction, &QAction::triggered, this, &DiagramImageItem::crop);
|
||||
|
||||
QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal"));
|
||||
mirrorH->setIcon(QET::Icons::ImageFlipHorizontal);
|
||||
QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical"));
|
||||
mirrorV->setIcon(QET::Icons::ImageFlipVertical);
|
||||
connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); });
|
||||
connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); });
|
||||
|
||||
QAction *restoreRatio = menu.data()->addAction(tr("Restaurer les proportions"));
|
||||
connect(restoreRatio, &QAction::triggered, this, &DiagramImageItem::restoreAspectRatio);
|
||||
|
||||
menu.data()->addSeparator();
|
||||
QAction *properties = menu.data()->addAction(tr("Propriétés..."));
|
||||
connect(properties, &QAction::triggered, this, &DiagramImageItem::editProperty);
|
||||
// menu.data()->addSeparator();
|
||||
// QAction *properties = menu.data()->addAction(tr("Propriétés..."));
|
||||
// connect(properties, &QAction::triggered, this, &DiagramImageItem::editProperty);
|
||||
|
||||
menu.data()->addSeparator();
|
||||
menu.data()->addActions(d_view->contextMenuActions());
|
||||
@@ -1454,6 +1667,15 @@ void DiagramImageItem::replaceImage()
|
||||
|
||||
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
|
||||
undo->setText(tr("Remplacer une image"));
|
||||
// Every call here is a separate, deliberate menu action with no
|
||||
// compound child of its own (unlike crop(), which always chains a
|
||||
// pos/rawPivot change and is naturally immune) -- two of them in a
|
||||
// row would carry the exact same object, property, and text, which
|
||||
// is indistinguishable from a legitimate merge to
|
||||
// QPropertyUndoCommand::mergeWith(). A dummy child (already treated
|
||||
// as "never merge" by that check) keeps each one its own, separate
|
||||
// undo step regardless.
|
||||
new QUndoCommand(undo);
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
|
||||
@@ -1497,6 +1719,11 @@ void DiagramImageItem::mirror(bool horizontal)
|
||||
|
||||
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
|
||||
undo->setText(horizontal ? tr("Miroir horizontal d'une image") : tr("Miroir vertical d'une image"));
|
||||
// See replaceImage()'s identical comment: a separate, deliberate
|
||||
// action with no compound child of its own, so a dummy one is
|
||||
// needed to stop two consecutive same-direction mirrors (identical
|
||||
// object, property, and text) from silently merging into one.
|
||||
new QUndoCommand(undo);
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
|
||||
@@ -1546,6 +1773,11 @@ void DiagramImageItem::setTransparentColor()
|
||||
|
||||
auto *undo = new QPropertyUndoCommand(this, "pixmap", oldPixmap, newPixmap);
|
||||
undo->setText(tr("Définir une couleur transparente"));
|
||||
// See replaceImage()'s identical comment: a separate, deliberate
|
||||
// action with no compound child of its own, so a dummy one is
|
||||
// needed to stop two consecutive transparency edits (identical
|
||||
// object, property, and text) from silently merging into one.
|
||||
new QUndoCommand(undo);
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
#include <QColor>
|
||||
#include <QList>
|
||||
#include <QStyleOptionGraphicsItem>
|
||||
#include <QVector>
|
||||
|
||||
class QDomElement;
|
||||
@@ -96,6 +97,19 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
QRectF boundingRect() const override;
|
||||
QString name() const override;
|
||||
|
||||
/// DXF export: replay this item's paint() on an arbitrary QPainter
|
||||
/// (e.g. one targeting DxfPaintDevice). paint() itself stays
|
||||
/// protected, as it should for the normal
|
||||
/// QGraphicsScene/QGraphicsView paint contract - this is a
|
||||
/// deliberate, narrow escape hatch for exporters, not a general
|
||||
/// relaxation of that contract. Matches CrossRefItem::paintForExport()
|
||||
/// exactly, for the identical reason.
|
||||
void paintForExport(QPainter *painter)
|
||||
{
|
||||
QStyleOptionGraphicsItem option;
|
||||
paint(painter, &option, nullptr);
|
||||
}
|
||||
|
||||
qreal scaleFactorX() const { return m_transform.scaleX; }
|
||||
qreal scaleFactorY() const { return m_transform.scaleY; }
|
||||
void setScaleFactorX(qreal factor);
|
||||
@@ -129,6 +143,10 @@ class DiagramImageItem : public QetGraphicsItem {
|
||||
void setTransparentColor();
|
||||
void crop();
|
||||
void restoreAspectRatio();
|
||||
void saveImageAs();
|
||||
void saveOriginalImageAs();
|
||||
void saveImagePixmapAs(const QPixmap &pixmap, const QString &dialogTitle, bool hasTransparency);
|
||||
static bool writeRasterAsSvg(const QPixmap &pixmap, const QString &path);
|
||||
static QPixmap computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList<ImageTransparentColorDialog::PickedColor> &colors);
|
||||
|
||||
void toggleHandleMode();
|
||||
|
||||
@@ -1097,18 +1097,39 @@ void QetShapeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event)
|
||||
|
||||
if (canConvertToPath)
|
||||
{
|
||||
QAction *convert = menu.data()->addAction(tr("Convertir en polyligne"));
|
||||
connect(convert, &QAction::triggered, this, &QetShapeItem::convertToPathExplicitly);
|
||||
// Only a sharp-cornered rectangle converts losslessly to
|
||||
// straight-edged polyline -- an ellipse (or arc) flattened
|
||||
// to its bounding rect's 4 corners wouldn't resemble the
|
||||
// original shape at all, and rounded corners would be
|
||||
// silently squared off. Both of those instead need an
|
||||
// actual Bezier curve to preserve their real geometry; the
|
||||
// label reflects whichever this particular shape will
|
||||
// actually get, rather than always claiming "polyligne"
|
||||
// regardless of what's about to happen.
|
||||
const bool needsBezier = (m_shapeType == Ellipse) || (m_xRadius > 0 || m_yRadius > 0);
|
||||
QAction *convert = menu.data()->addAction(needsBezier ? tr("Convertir en courbe de Bézier") : tr("Convertir en polyligne"));
|
||||
if(needsBezier && m_shapeType == Rectangle) {
|
||||
convert->setIcon(QET::Icons::RectToBezier);
|
||||
}
|
||||
else if(needsBezier && m_shapeType == Ellipse) {
|
||||
convert->setIcon(QET::Icons::EllipseToBezier);
|
||||
}
|
||||
else {
|
||||
convert->setIcon(QET::Icons::RectToPolyline);
|
||||
}
|
||||
connect(convert, &QAction::triggered, this, &QetShapeItem::convertToPathOrPolygon);
|
||||
}
|
||||
|
||||
QAction *mirrorH = menu.data()->addAction(tr("Miroir horizontal"));
|
||||
mirrorH->setIcon(QET::Icons::ImageFlipHorizontal);
|
||||
QAction *mirrorV = menu.data()->addAction(tr("Miroir vertical"));
|
||||
mirrorV->setIcon(QET::Icons::ImageFlipVertical);
|
||||
connect(mirrorH, &QAction::triggered, this, [this]() { mirror(true); });
|
||||
connect(mirrorV, &QAction::triggered, this, [this]() { mirror(false); });
|
||||
|
||||
menu.data()->addSeparator();
|
||||
QAction *properties = menu.data()->addAction(tr("Propriétés..."));
|
||||
connect(properties, &QAction::triggered, this, &QetShapeItem::editProperty);
|
||||
//menu.data()->addSeparator();
|
||||
//QAction *properties = menu.data()->addAction(tr("Propriétés..."));
|
||||
//connect(properties, &QAction::triggered, this, &QetShapeItem::editProperty);
|
||||
|
||||
menu.data()->addSeparator();
|
||||
menu.data()->addActions(d_view->contextMenuActions());
|
||||
@@ -1929,34 +1950,207 @@ void QetShapeItem::removePathPoint(int nodeIndex)
|
||||
rebuildHandles();
|
||||
}
|
||||
|
||||
void QetShapeItem::convertToPathExplicitly()
|
||||
/**
|
||||
@brief QetShapeItem::convertToPathOrPolygon
|
||||
Context-menu action, explicitly requested rather than triggered by an
|
||||
Alt+drag (see promoteRectangleOrEllipseToPolygon() for that separate
|
||||
mechanism). A sharp-cornered rectangle converts losslessly to a
|
||||
4-corner polygon -- its 4 corners already are exactly what a
|
||||
rectangle is. Anything else offered here (an ellipse/arc, or a
|
||||
rectangle with rounded corners) used to get the exact same
|
||||
treatment, which is wrong: an ellipse flattened to its bounding
|
||||
rect's 4 corners doesn't resemble an ellipse at all, and rounded
|
||||
corners would be silently squared off. Both instead become a
|
||||
genuine Bezier Path, built from the two verified helper functions
|
||||
below (numerically confirmed against Qt's own arcTo()/addRoundedRect()
|
||||
output -- worst-case error a small fraction of a unit on shapes
|
||||
roughly 100-250 units across, i.e. visually indistinguishable, not
|
||||
merely "close enough to eyeball").
|
||||
*/
|
||||
void QetShapeItem::convertToPathOrPolygon()
|
||||
{
|
||||
if (m_shapeType != Rectangle && m_shapeType != Ellipse)
|
||||
return;
|
||||
|
||||
const bool hasRoundedCorners = (m_shapeType == Rectangle) && (m_xRadius > 0 || m_yRadius > 0);
|
||||
// Captured before m_shapeType changes below -- name() reports
|
||||
// whatever the CURRENT type is, and by the time the undo text is
|
||||
// built further down, m_shapeType has already become Polygon or
|
||||
// Path, so calling name() at that point would describe the shape's
|
||||
// new type, not what it actually was before conversion ("Convertir
|
||||
// une polyligne en polyligne" instead of "Convertir un rectangle en
|
||||
// polyligne").
|
||||
const QString originalName = name();
|
||||
const QDomElement before = snapshotXml();
|
||||
|
||||
QPolygonF corners;
|
||||
const QRectF r = localRect();
|
||||
corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
|
||||
|
||||
prepareGeometryChange();
|
||||
m_shapeType = Polygon;
|
||||
m_polygon = corners;
|
||||
m_closed = true;
|
||||
|
||||
if (m_shapeType == Rectangle && !hasRoundedCorners)
|
||||
{
|
||||
QPolygonF corners;
|
||||
const QRectF r = localRect();
|
||||
corners << r.topLeft() << r.topRight() << r.bottomRight() << r.bottomLeft();
|
||||
m_shapeType = Polygon;
|
||||
m_polygon = corners;
|
||||
m_closed = true;
|
||||
}
|
||||
else if (m_shapeType == Rectangle) // rounded corners
|
||||
{
|
||||
m_nodes = bezierNodesForRoundedRect(QRectF(m_P1, m_P2), m_xRadius, m_yRadius);
|
||||
m_shapeType = Path;
|
||||
m_closed = true;
|
||||
}
|
||||
else // Ellipse, full or arc
|
||||
{
|
||||
const QRectF r(m_P1, m_P2);
|
||||
if (isFullEllipse())
|
||||
{
|
||||
m_nodes = bezierNodesForArc(r, 0, 360);
|
||||
m_closed = true;
|
||||
}
|
||||
else if (m_arcClosure == Pie)
|
||||
{
|
||||
m_nodes = bezierNodesForArc(r, m_startAngle, spanAngle());
|
||||
PathNode centerNode;
|
||||
centerNode.anchor = r.center();
|
||||
m_nodes.prepend(centerNode);
|
||||
m_closed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Chord leaves the wrap-around segment's handles unset, which
|
||||
// falls through to QPainterPath::closeSubpath()'s own implicit
|
||||
// straight line -- exactly the chord behaviour -- when m_closed
|
||||
// is true; NoClosure is the same node list, just left open.
|
||||
m_nodes = bezierNodesForArc(r, m_startAngle, spanAngle());
|
||||
m_closed = (m_arcClosure == Chord);
|
||||
}
|
||||
m_shapeType = Path;
|
||||
}
|
||||
|
||||
const QDomElement after = snapshotXml();
|
||||
|
||||
if (diagram())
|
||||
{
|
||||
auto *undo = new PromoteShapeCommand(this, before, after);
|
||||
undo->setText(tr("Convertir %1 en polyligne").arg(name()));
|
||||
undo->setText(m_shapeType == Path
|
||||
? tr("Convertir %1 en courbe de Bézier").arg(originalName)
|
||||
: tr("Convertir %1 en polyligne").arg(originalName));
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
|
||||
rebuildHandles();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetShapeItem::bezierNodesForArc
|
||||
Bezier approximation of an elliptical arc, split into <=90-degree
|
||||
segments (the standard cap for this technique to stay visually
|
||||
exact -- a single segment starts drifting noticeably past that).
|
||||
Each segment's handle length is (4/3)*tan(segment_span/4), the
|
||||
general-angle form of the well-known ~0.5523 "kappa" constant for
|
||||
an exact quarter circle. A full 360-degree span is a special case:
|
||||
it produces exactly `segments` nodes rather than `segments+1`
|
||||
(the very last one would just be a duplicate of the first at the
|
||||
same anchor point) and wires the wrap-around segment's own handles
|
||||
explicitly, so the loop closes as a continuous curve rather than
|
||||
the straight "chord" line a generic closed-path wrap-around would
|
||||
otherwise fall back to.
|
||||
@param rect the ellipse's bounding rect
|
||||
@param startAngleDeg arc start angle, matching QPainterPath::arcTo's own convention
|
||||
@param spanAngleDeg arc angular span, same convention (may be negative)
|
||||
@return nodes forming the arc; caller sets Path/m_closed and prepends
|
||||
a centre node itself for a Pie-style closure
|
||||
*/
|
||||
QVector<QetShapeItem::PathNode> QetShapeItem::bezierNodesForArc(const QRectF &rect, qreal startAngleDeg, qreal spanAngleDeg)
|
||||
{
|
||||
const bool fullLoop = qFuzzyCompare(qAbs(spanAngleDeg), 360.0);
|
||||
const qreal cx = rect.center().x(), cy = rect.center().y();
|
||||
const qreal rx = rect.width() / 2.0, ry = rect.height() / 2.0;
|
||||
|
||||
auto pointAt = [&](qreal angleDeg) {
|
||||
const qreal a = qDegreesToRadians(angleDeg);
|
||||
return QPointF(cx + rx * qCos(a), cy - ry * qSin(a));
|
||||
};
|
||||
auto tangentAt = [&](qreal angleDeg) {
|
||||
const qreal a = qDegreesToRadians(angleDeg);
|
||||
return QPointF(-rx * qSin(a), -ry * qCos(a));
|
||||
};
|
||||
|
||||
const int segments = qMax(1, int(qCeil(qAbs(spanAngleDeg) / 90.0)));
|
||||
const qreal segSpan = spanAngleDeg / segments;
|
||||
const qreal handleLen = (4.0 / 3.0) * qTan(qDegreesToRadians(qAbs(segSpan)) / 4.0);
|
||||
|
||||
const int nodeCount = fullLoop ? segments : segments + 1;
|
||||
QVector<PathNode> nodes(nodeCount);
|
||||
for (int i = 0; i < nodeCount; ++i)
|
||||
{
|
||||
nodes[i].anchor = pointAt(startAngleDeg + i * segSpan);
|
||||
nodes[i].kind = NodeKind::Smooth;
|
||||
}
|
||||
for (int i = 0; i < nodeCount; ++i)
|
||||
{
|
||||
const QPointF tangent = tangentAt(startAngleDeg + i * segSpan);
|
||||
if (fullLoop || i < nodeCount - 1)
|
||||
nodes[i].outHandle = tangent * handleLen;
|
||||
if (fullLoop || i > 0)
|
||||
nodes[i % nodeCount].inHandle = -tangent * handleLen;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetShapeItem::bezierNodesForRoundedRect
|
||||
Bezier equivalent of QPainterPath::addRoundedRect(): 8 anchors (each
|
||||
edge's two ends), straight lines along the 4 edges, and a Bezier
|
||||
quarter-turn at each of the 4 corners using the same handle-length
|
||||
formula as bezierNodesForArc() (with segSpan fixed at 90 degrees,
|
||||
since a rounded rect's corners always are). xRadius/yRadius are
|
||||
clamped to at most half the rect's own width/height, matching how
|
||||
QPainterPath::addRoundedRect() itself behaves for an
|
||||
over-large radius.
|
||||
@param rect the rectangle's own corner points, as a QRectF
|
||||
@param xRadius corner radius along the x axis
|
||||
@param yRadius corner radius along the y axis
|
||||
@return 8 nodes forming the closed rounded-rectangle outline
|
||||
*/
|
||||
QVector<QetShapeItem::PathNode> QetShapeItem::bezierNodesForRoundedRect(const QRectF &rect, qreal xRadius, qreal yRadius)
|
||||
{
|
||||
const QRectF r = rect.normalized();
|
||||
const qreal rx = qBound(0.0, xRadius, r.width() / 2.0);
|
||||
const qreal ry = qBound(0.0, yRadius, r.height() / 2.0);
|
||||
const qreal kx = rx * 0.5522847498;
|
||||
const qreal ky = ry * 0.5522847498;
|
||||
|
||||
const QVector<QPointF> anchors = {
|
||||
QPointF(r.left() + rx, r.top()),
|
||||
QPointF(r.right() - rx, r.top()),
|
||||
QPointF(r.right(), r.top() + ry),
|
||||
QPointF(r.right(), r.bottom() - ry),
|
||||
QPointF(r.right() - rx, r.bottom()),
|
||||
QPointF(r.left() + rx, r.bottom()),
|
||||
QPointF(r.left(), r.bottom() - ry),
|
||||
QPointF(r.left(), r.top() + ry),
|
||||
};
|
||||
|
||||
QVector<PathNode> nodes;
|
||||
nodes.reserve(8);
|
||||
for (const QPointF &p : anchors)
|
||||
{
|
||||
PathNode n;
|
||||
n.anchor = p;
|
||||
n.kind = NodeKind::Smooth;
|
||||
nodes.append(n);
|
||||
}
|
||||
|
||||
nodes[1].outHandle = QPointF(kx, 0); nodes[2].inHandle = QPointF(0, -ky);
|
||||
nodes[3].outHandle = QPointF(0, ky); nodes[4].inHandle = QPointF(kx, 0);
|
||||
nodes[5].outHandle = QPointF(-kx, 0); nodes[6].inHandle = QPointF(0, ky);
|
||||
nodes[7].outHandle = QPointF(0, -ky); nodes[0].inHandle = QPointF(-kx, 0);
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetShapeItem::mirror
|
||||
Flips the shape around its own current pivot -- horizontal negates
|
||||
@@ -2477,6 +2671,8 @@ void QetShapeItem::handlerMousePressEvent(int handlerIndex)
|
||||
m_old_polygon = m_polygon;
|
||||
m_old_xRadius = m_xRadius;
|
||||
m_old_yRadius = m_yRadius;
|
||||
m_old_startAngle = m_startAngle;
|
||||
m_old_endAngle = m_endAngle;
|
||||
m_old_transform = m_transform;
|
||||
m_old_pos = pos();
|
||||
m_old_nodes = m_nodes;
|
||||
@@ -2555,11 +2751,16 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "rect", QRectF(m_old_P1, m_old_P2), QRectF(m_P1, m_P2).normalized());
|
||||
}
|
||||
if (undo)
|
||||
undo->setText(tr("Redimensionner %1").arg(name()));
|
||||
break;
|
||||
|
||||
case HandleRole::Rotate:
|
||||
if (!qFuzzyCompare(m_transform.rotation, m_old_transform.rotation))
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "rotation", m_old_transform.rotation, m_transform.rotation);
|
||||
undo->setText(tr("Faire pivoter %1").arg(name()));
|
||||
}
|
||||
break;
|
||||
|
||||
case HandleRole::SkewEdge:
|
||||
@@ -2567,6 +2768,8 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
|
||||
undo = new QPropertyUndoCommand(this, "skewX", m_old_transform.skewX, m_transform.skewX);
|
||||
else if (!qFuzzyCompare(m_transform.skewY, m_old_transform.skewY))
|
||||
undo = new QPropertyUndoCommand(this, "skewY", m_old_transform.skewY, m_transform.skewY);
|
||||
if (undo)
|
||||
undo->setText(tr("Incliner %1").arg(name()));
|
||||
break;
|
||||
|
||||
case HandleRole::Pivot:
|
||||
@@ -2583,20 +2786,37 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "xRadius", m_old_xRadius, m_xRadius);
|
||||
new QPropertyUndoCommand(this, "yRadius", m_old_yRadius, m_yRadius, undo);
|
||||
undo->setText(tr("Arrondir les coins d'%1").arg(name()));
|
||||
}
|
||||
break;
|
||||
|
||||
case HandleRole::ArcEndpoint:
|
||||
// startAngle/endAngle changes are cosmetic-cost enough (and
|
||||
// re-derived from each other on snap-to-full-ellipse) that
|
||||
// they are intentionally not wrapped in undo here yet -- flag
|
||||
// for a follow-up once ArcEndpoint dragging ships in the UI.
|
||||
// The snap-to-full-ellipse behaviour in setStartAngle()/
|
||||
// setEndAngle() (see anglesGeometricallyAdjacent()) can
|
||||
// reset BOTH angles at once even though only one endpoint
|
||||
// was actually dragged, so both are checked here regardless
|
||||
// of which handle (slot 0 or 1) triggered this -- the same
|
||||
// reasoning as CornerRadius checking both xRadius and
|
||||
// yRadius above.
|
||||
if (!qFuzzyCompare(m_startAngle, m_old_startAngle))
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "startAngle", m_old_startAngle, m_startAngle);
|
||||
if (!qFuzzyCompare(m_endAngle, m_old_endAngle))
|
||||
new QPropertyUndoCommand(this, "endAngle", m_old_endAngle, m_endAngle, undo);
|
||||
}
|
||||
else if (!qFuzzyCompare(m_endAngle, m_old_endAngle))
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "endAngle", m_old_endAngle, m_endAngle);
|
||||
}
|
||||
if (undo)
|
||||
undo->setText(tr("Modifier l'angle d'un arc"));
|
||||
break;
|
||||
|
||||
case HandleRole::PathAnchor:
|
||||
if (m_shapeType == Polygon && m_polygon != m_old_polygon)
|
||||
{
|
||||
undo = new QPropertyUndoCommand(this, "polygon", m_old_polygon, m_polygon);
|
||||
undo->setText(tr("Modifier la forme d'%1").arg(name()));
|
||||
}
|
||||
else if (m_shapeType == Path && m_nodes != m_old_nodes)
|
||||
{
|
||||
@@ -2611,6 +2831,7 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
|
||||
m_nodes = after;
|
||||
const QDomElement afterXml = snapshotXml();
|
||||
undo = new PromoteShapeCommand(this, before, afterXml);
|
||||
undo->setText(tr("Modifier la forme d'%1").arg(name()));
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -2624,14 +2845,38 @@ void QetShapeItem::handlerMouseReleaseEvent(int handlerIndex)
|
||||
m_nodes = after;
|
||||
const QDomElement afterXml = snapshotXml();
|
||||
undo = new PromoteShapeCommand(this, before, afterXml);
|
||||
undo->setText(tr("Modifier la courbure d'%1").arg(name()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (undo)
|
||||
{
|
||||
// Defensive fallback only -- every role above now sets its own,
|
||||
// distinct label directly (Resize/Rotate/SkewEdge/CornerRadius/
|
||||
// PathAnchor/PathControlIn/PathControlOut used to all fall
|
||||
// through to this same generic text, making the undo list
|
||||
// unable to tell completely different edits apart); this only
|
||||
// still matters if some future role is ever added without
|
||||
// setting one of its own.
|
||||
if (undo->text().isEmpty())
|
||||
undo->setText(tr("Modifier %1").arg(name()));
|
||||
|
||||
// Every push here is one complete, finished gesture (press,
|
||||
// drag, release) -- never a continuation of an earlier one, the
|
||||
// way ArcEditor's slider deliberately pushes many commands
|
||||
// during a single ongoing drag and wants them to merge.
|
||||
// QPropertyUndoCommand::mergeWith() already treats any command
|
||||
// with children as never mergeable; a dummy child guarantees
|
||||
// that here regardless of which role produced undo. Without
|
||||
// this, a single-property change (Rotate and SkewEdge always
|
||||
// are; Resize and ArcEndpoint sometimes are, when only one of
|
||||
// their two properties actually changed) would silently
|
||||
// coalesce into whatever identically-labelled edit came right
|
||||
// before it -- even after a deselect/reselect proved they were
|
||||
// two separate actions, since the shared label is otherwise
|
||||
// indistinguishable from a genuine continuation.
|
||||
new QUndoCommand(undo);
|
||||
diagram()->undoStack().push(undo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,9 @@ class QetShapeItem : public QetGraphicsItem
|
||||
void repositionHandles(); // moves existing handler items -- safe to call every frame of a live drag
|
||||
void insertPoint();
|
||||
void removePoint();
|
||||
void convertToPathExplicitly(); // context-menu action; see promoteRectangleOrEllipseToPolygon()
|
||||
void convertToPathOrPolygon(); // context-menu action; see promoteRectangleOrEllipseToPolygon() for the separate Alt+drag mechanism
|
||||
static QVector<PathNode> bezierNodesForArc(const QRectF &rect, qreal startAngleDeg, qreal spanAngleDeg);
|
||||
static QVector<PathNode> bezierNodesForRoundedRect(const QRectF &rect, qreal xRadius, qreal yRadius);
|
||||
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
|
||||
|
||||
@@ -312,7 +314,9 @@ class QetShapeItem : public QetGraphicsItem
|
||||
qreal m_xRadius = 0,
|
||||
m_yRadius = 0,
|
||||
m_old_xRadius,
|
||||
m_old_yRadius;
|
||||
m_old_yRadius,
|
||||
m_old_startAngle,
|
||||
m_old_endAngle;
|
||||
|
||||
ShapeTransform m_transform;
|
||||
ShapeTransform m_old_transform;
|
||||
|
||||
@@ -65,6 +65,7 @@ namespace QET {
|
||||
QIcon EditCopy;
|
||||
QIcon EditCut;
|
||||
QIcon EditDelete;
|
||||
QIcon EditOpacity;
|
||||
QIcon EditPaste;
|
||||
QIcon EditRedo;
|
||||
QIcon EditRename;
|
||||
@@ -86,6 +87,7 @@ namespace QET {
|
||||
QIcon ElementEdit;
|
||||
QIcon ElementNew;
|
||||
QIcon ElementSmall;
|
||||
QIcon EllipseToBezier;
|
||||
QIcon EndLineCircle;
|
||||
QIcon EndLineDiamond;
|
||||
QIcon EndLineNone;
|
||||
@@ -115,6 +117,8 @@ namespace QET {
|
||||
QIcon Hide;
|
||||
QIcon Home;
|
||||
QIcon HotSpot;
|
||||
QIcon ImageFlipHorizontal;
|
||||
QIcon ImageFlipVertical;
|
||||
QIcon InsertImage;
|
||||
QIcon Lower;
|
||||
QIcon IC_MoveFile;
|
||||
@@ -156,6 +160,8 @@ namespace QET {
|
||||
QIcon QETOxygenLogo;
|
||||
QIcon QtLogo;
|
||||
QIcon Raise;
|
||||
QIcon RectToBezier;
|
||||
QIcon RectToPolyline;
|
||||
QIcon Remove;
|
||||
QIcon Restore;
|
||||
QIcon RunDxf;
|
||||
@@ -169,6 +175,7 @@ namespace QET {
|
||||
QIcon TitleBlock;
|
||||
QIcon TitleBlockBottom;
|
||||
QIcon TitleBlockRight;
|
||||
QIcon TransformCrop;
|
||||
QIcon TransformRotate;
|
||||
QIcon UserInformations;
|
||||
QIcon ViewFitWidth;
|
||||
@@ -470,6 +477,7 @@ void QET::Icons::initIcons()
|
||||
EditCut .addFile(":/ico/22x22/edit-cut.png");
|
||||
EditDelete .addFile(":/ico/16x16/edit-delete.png");
|
||||
EditDelete .addFile(":/ico/22x22/edit-delete.png");
|
||||
EditOpacity .addFile(":/ico/breeze-icons/scalable/apps/hidef/edit-opacity.svg");
|
||||
EditPaste .addFile(":/ico/22x22/edit-paste.png");
|
||||
EditPaste .addFile(":/ico/16x16/edit-paste.png");
|
||||
if (rtl) {
|
||||
@@ -518,6 +526,7 @@ void QET::Icons::initIcons()
|
||||
ElementNew .addFile(":/ico/16x16/element-new.png");
|
||||
ElementNew .addFile(":/ico/22x22/element-new.png");
|
||||
ElementSmall .addFile(":/ico/16x16/element.png");
|
||||
EllipseToBezier .addFile(":/ico/generated/ellipse-to-bezier.svg");
|
||||
EndLineCircle .addFile(":/ico/16x16/endline-circle.png");
|
||||
EndLineDiamond .addFile(":/ico/16x16/endline-diamond.png");
|
||||
EndLineNone .addFile(":/ico/16x16/endline-none.png");
|
||||
@@ -557,6 +566,8 @@ void QET::Icons::initIcons()
|
||||
Home .addFile(":/ico/16x16/go-home.png");
|
||||
Home .addFile(":/ico/22x22/go-home.png");
|
||||
HotSpot .addFile(":/ico/22x22/hotspot.png");
|
||||
ImageFlipHorizontal .addFile(":/ico/breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg");
|
||||
ImageFlipVertical .addFile(":/ico/breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg");
|
||||
InsertImage .addFile(":/ico/22x22/insert-image.png");
|
||||
Lower .addFile(":/ico/22x22/lower.png");
|
||||
IC_MoveFile .addFile(":/ico/16x16/item-move.png");
|
||||
@@ -615,6 +626,8 @@ void QET::Icons::initIcons()
|
||||
QETVideo .addFile(":/ico/16x16/kdenlive-show-video.png");
|
||||
QtLogo .addFile(":/ico/16x16/qt.png");
|
||||
Raise .addFile(":/ico/22x22/raise.png");
|
||||
RectToBezier .addFile(":/ico/generated/rect-to-bezier.svg");
|
||||
RectToPolyline .addFile(":/ico/generated/rect-to-polyline.svg");
|
||||
Remove .addFile(":/ico/16x16/list-remove.png");
|
||||
Remove .addFile(":/ico/22x22/list-remove.png");
|
||||
resize_image .addFile(":/ico/22x22/transform-scale.png");
|
||||
@@ -633,6 +646,7 @@ void QET::Icons::initIcons()
|
||||
TitleBlock .addFile(":/ico/22x22/label.png");
|
||||
TitleBlockBottom .addFile(":/ico/22x22/titleblock-bottom.png");
|
||||
TitleBlockRight .addFile(":/ico/22x22/titleblock-right.png");
|
||||
TransformCrop .addFile(":/ico/breeze-icons/scalable/apps/hidef/transform-crop.svg");
|
||||
TransformRotate .addFile(":/ico/16x16/transform-rotate.png");
|
||||
UserInformations .addFile(":/ico/16x16/preferences-desktop-user.png");
|
||||
UserInformations .addFile(":/ico/22x22/preferences-desktop-user.png");
|
||||
|
||||
@@ -72,6 +72,7 @@ namespace QET {
|
||||
extern QIcon EditCopy;
|
||||
extern QIcon EditCut;
|
||||
extern QIcon EditDelete;
|
||||
extern QIcon EditOpacity;
|
||||
extern QIcon EditPaste;
|
||||
extern QIcon EditRedo;
|
||||
extern QIcon EditRename;
|
||||
@@ -92,6 +93,7 @@ namespace QET {
|
||||
extern QIcon ElementDelete;
|
||||
extern QIcon ElementEdit;
|
||||
extern QIcon ElementNew;
|
||||
extern QIcon EllipseToBezier;
|
||||
extern QIcon EndLineCircle;
|
||||
extern QIcon EndLineDiamond;
|
||||
extern QIcon EndLineNone;
|
||||
@@ -121,6 +123,8 @@ namespace QET {
|
||||
extern QIcon Hide;
|
||||
extern QIcon Home;
|
||||
extern QIcon HotSpot;
|
||||
extern QIcon ImageFlipHorizontal;
|
||||
extern QIcon ImageFlipVertical;
|
||||
extern QIcon InsertImage;
|
||||
extern QIcon Lower;
|
||||
extern QIcon IC_MoveFile;
|
||||
@@ -164,6 +168,8 @@ namespace QET {
|
||||
extern QIcon QETOxygenLogo;
|
||||
extern QIcon QtLogo;
|
||||
extern QIcon Raise;
|
||||
extern QIcon RectToBezier;
|
||||
extern QIcon RectToPolyline;
|
||||
extern QIcon Remove;
|
||||
extern QIcon Restore;
|
||||
extern QIcon RunDxf;
|
||||
@@ -177,6 +183,7 @@ namespace QET {
|
||||
extern QIcon TitleBlock;
|
||||
extern QIcon TitleBlockBottom;
|
||||
extern QIcon TitleBlockRight;
|
||||
extern QIcon TransformCrop;
|
||||
extern QIcon TransformRotate;
|
||||
extern QIcon UserInformations;
|
||||
extern QIcon ViewFitWidth;
|
||||
|
||||
@@ -112,7 +112,8 @@ ImageTransparentColorDialog::ImageTransparentColorDialog(const QPixmap &basePixm
|
||||
|
||||
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
|
||||
m_startedWithColors = !m_pickedColors.isEmpty();
|
||||
updateOkEnabled();
|
||||
|
||||
auto *grid = new QGridLayout;
|
||||
grid->addWidget(new QLabel(tr("Image source")), 0, 0);
|
||||
@@ -159,7 +160,7 @@ void ImageTransparentColorDialog::onColorPicked(const QColor &color)
|
||||
return;
|
||||
|
||||
m_pickedColors.append({color, m_lastToleranceUsed});
|
||||
m_okButton->setEnabled(true);
|
||||
updateOkEnabled();
|
||||
rebuildSwatches();
|
||||
updatePreview();
|
||||
}
|
||||
@@ -183,6 +184,22 @@ void ImageTransparentColorDialog::setToleranceForIndex(int index, int value)
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ImageTransparentColorDialog::updateOkEnabled
|
||||
OK is disabled only for the one case where accepting would genuinely
|
||||
do nothing: a dialog that started with no colours and still has
|
||||
none. Removing every colour from a dialog that DID start with some
|
||||
is a different, meaningful action -- clearing all transparency back
|
||||
to a plain, opaque image -- and has to stay confirmable, not silently
|
||||
blocked the same way. Re-run after every add or remove, since either
|
||||
can cross the "any colours at all, ever" threshold in either
|
||||
direction.
|
||||
*/
|
||||
void ImageTransparentColorDialog::updateOkEnabled()
|
||||
{
|
||||
m_okButton->setEnabled(!m_pickedColors.isEmpty() || m_startedWithColors);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ImageTransparentColorDialog::removeColor
|
||||
Removes one colour from the set -- the counterpart onColorPicked()
|
||||
@@ -196,7 +213,7 @@ void ImageTransparentColorDialog::removeColor(int index)
|
||||
return;
|
||||
|
||||
m_pickedColors.removeAt(index);
|
||||
m_okButton->setEnabled(!m_pickedColors.isEmpty());
|
||||
updateOkEnabled();
|
||||
rebuildSwatches();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
@@ -142,11 +142,13 @@ class ImageTransparentColorDialog : public QDialog
|
||||
void removeColor(int index);
|
||||
void rebuildSwatches();
|
||||
void updatePreview();
|
||||
void updateOkEnabled();
|
||||
static QPixmap onCheckerboard(const QImage &image);
|
||||
|
||||
QImage m_sourceImage;
|
||||
QImage m_previewSourceImage; // downsampled -- see ClickableImageLabel::displayImage()'s comment for why
|
||||
QList<PickedColor> m_pickedColors;
|
||||
bool m_startedWithColors = false; // whether existingColors was non-empty -- see updateOkEnabled()'s comment for why this matters
|
||||
int m_lastToleranceUsed = 10; // seeds a newly-picked colour's own tolerance, so successive picks in one session feel consistent rather than each resetting to some fixed default
|
||||
|
||||
ClickableImageLabel *m_sourceLabel;
|
||||
|
||||
@@ -77,3 +77,11 @@ target_link_libraries(
|
||||
${KF_PRIVATE_LIBRARIES}
|
||||
${QET_PRIVATE_LIBRARIES})
|
||||
|
||||
# diagramsortkeys.h is a header-only helper (no QET link deps needed
|
||||
# beyond Qt itself), so this test builds independently of the rest of
|
||||
# the QET sources.
|
||||
add_executable(tst_diagramsortkeys tst_diagramsortkeys.cpp)
|
||||
add_test(NAME tst_diagramsortkeys COMMAND tst_diagramsortkeys)
|
||||
target_include_directories(tst_diagramsortkeys PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_diagramsortkeys PRIVATE Qt::Test)
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include "diagramsortkeys.h"
|
||||
|
||||
class tst_diagramsortkeys : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
// positionKey() must sort the same way the underlying coordinates do,
|
||||
// including across differing integer-part digit widths and across the
|
||||
// negative/positive boundary. A previous implementation formatted
|
||||
// coordinates with plain "%.4f" and compared the resulting strings
|
||||
// directly, which sorted "15.0000" before "5.0000".
|
||||
void sortsLikeNumbers_data()
|
||||
{
|
||||
QTest::addColumn<QPointF>("smaller");
|
||||
QTest::addColumn<QPointF>("larger");
|
||||
|
||||
QTest::newRow("single vs double digit") << QPointF(5.0, 0.0) << QPointF(15.0, 0.0);
|
||||
QTest::newRow("double vs triple digit") << QPointF(0.0, 99.0) << QPointF(0.0, 100.0);
|
||||
QTest::newRow("negative vs negative") << QPointF(-15.0, 0.0) << QPointF(-5.0, 0.0);
|
||||
QTest::newRow("negative vs positive") << QPointF(-1.0, 0.0) << QPointF(1.0, 0.0);
|
||||
QTest::newRow("negative vs zero") << QPointF(0.0, -0.0001) << QPointF(0.0, 0.0);
|
||||
QTest::newRow("fractional precision") << QPointF(1.0001, 0.0) << QPointF(1.001, 0.0);
|
||||
}
|
||||
|
||||
void sortsLikeNumbers()
|
||||
{
|
||||
QFETCH(QPointF, smaller);
|
||||
QFETCH(QPointF, larger);
|
||||
|
||||
QVERIFY(DiagramSortKeys::positionKey(smaller) < DiagramSortKeys::positionKey(larger));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_APPLESS_MAIN(tst_diagramsortkeys)
|
||||
|
||||
#include "tst_diagramsortkeys.moc"
|
||||