Add Ctrl+D: duplicate the selection, offset by a configured grid step (#991)

Split from #913's second suggestion. There was no shortcut for the common
"duplicate with offset" convention; the nearest existing feature,
"Collage multiple", is a different workflow (a dialog for repeating a
paste in a grid pattern, not a one-shot duplicate).

Ctrl+D copies the selection and places it immediately, offset by a
configured spacing and direction -- no interactive follow-the-cursor
step, unlike Ctrl+V. The first press (or after the setting is explicitly
reopened) shows DuplicateOffsetDialog: spacing in grid steps, direction
up/down/left/right. Every later press reuses whatever was confirmed then,
silently, so a row of copies is one key held down and tapped, not a
dialog every time -- unattended, repeatable stamping is the actual point
of a duplicate shortcut, which a dialog or an interactive placement step
on every press would defeat. A separate "Configurer la duplication..."
entry reopens the dialog on demand to change the setting later. Cancel
leaves the diagram untouched -- verified, not assumed: qet_diff against
the saved file shows 0 added.

Chaining ("keep tapping to lay out a row") needs no special handling:
QET already reselects whatever a paste just added
(PasteDiagramCommand::redo()), so the next Ctrl+D naturally continues
from the copy just placed rather than the original.

The offset is applied by hand rather than by asking paste()/fromXml() to
place the copy at a target position. Both of those feed the position
through Diagram::snapToGrid(), which reads
QApplication::keyboardModifiers() and rounds to the nearest PIXEL instead
of the grid whenever Ctrl is held -- and Ctrl is always held here, this
action's own shortcut being Ctrl+D. Measured before settling on this:
routing the offset through paste() first produced copies off-grid on both
axes, by an amount that tracked the selection's own bounding-box geometry
rather than being a fixed error -- caught by qet-mcp's qet_elements
against the saved file, not by eye. fromXml() is instead called with no
position argument at all (leaves every item at its source coordinates,
landing the copy on top of the originals -- (0,0) is not a position, this
is "keep the source coordinates"), and the offset is added directly with
setPos(). A plain addition cannot be off by a rounding rule that never
runs.

Conductors are not in the hand-translated set: fromXml() itself does not
reposition them either -- they load after elements are already in their
final place and take their geometry from their terminals, which have
already moved with the elements that own them. Verified this holds: drew
a conductor by hand between two elements (drag, not click-click),
selected both, Ctrl+D, and the new conductor correctly joins the two new
elements via qet_conductors -- not the originals, not a mix.

Verified end-to-end on a built binary via qet-mcp, not by eye:

  before          L2 (303,207)  L9 (512,196)     -- deliberately off-grid
  spacing=2, down    (303,227)     (512,216)      -- +0,+20 exactly
  same again, 2nd    (303,247)     (512,236)      -- +0,+20 again, chained

Both elements land exactly the configured offset from their immediate
source regardless of the selection's own alignment. Qt 6.10.2, ctest
11/11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-23 11:30:30 +12:00
parent 6fc7e4a090
commit fa213d90d9
7 changed files with 306 additions and 0 deletions
+76
View File
@@ -445,6 +445,82 @@ void DiagramView::pasteHere()
paste(mapToScene(m_paste_here_pos));
}
/**
@brief DiagramView::duplicate
Copy the current selection and place the copy at @p stepOffset grid
steps from it, landing immediately rather than following the cursor
like Ctrl+V does (bugtracker #991). @p stepOffset comes from
DuplicateOffsetDialog: (1, 0) is one grid step right, (0, -1) is one
grid step up, and so on -- QET's own scene axes, X right and Y down.
No interactive placement step on purpose: the point of a duplicate
shortcut is unattended, repeatable stamping (configure the offset
once, then tap Ctrl+D to lay out a row), which following the cursor
would interrupt on every press. QET already reselects whatever a
paste just added (see PasteDiagramCommand::redo()), so the next
Ctrl+D naturally continues from the copy just placed, not the
original -- a press-and-hold row falls out of that for free, with no
special-casing needed here for "keep going from the last one".
The offset is applied by hand rather than by asking paste()/
Diagram::fromXml() to place the copy at a target position. Both of
those feed the position through Diagram::snapToGrid(), which reads
QApplication::keyboardModifiers() and rounds to the nearest PIXEL
instead of the grid whenever Ctrl is held -- and Ctrl is always held
here, this action's own shortcut being Ctrl+D. Measured the hard way
before settling on this: routing the offset through paste() first
produced copies off-grid on both axes, by an amount that tracked the
selection's own bounding-box geometry rather than being a fixed
error. fromXml() is instead called with no position at all, which
leaves every item at its source coordinates (landing the copy
exactly on top of the originals -- (0, 0) is not a position, this is
"keep the source coordinates"), and the offset is added directly
with setPos(). A plain addition cannot be off by a rounding rule
that never runs.
Conductors are not in the translated set: fromXml() itself does not
reposition them either -- they are loaded from XML after elements
are already in their final place and take their geometry from their
terminals, which have already moved with the elements that own
them. Likewise dynamic element texts are not translated separately:
they are children of their element and move with it under Qt's
normal parent-child transform.
*/
void DiagramView::duplicate(const QPoint &stepOffset)
{
if (!isInteractive() || m_diagram->isReadOnly()) return;
const QList<QGraphicsItem *> selection = m_diagram->selectedItems();
if (selection.isEmpty()) return;
QSettings settings;
const int x_grid = settings.value(QStringLiteral("diagrameditor/Xgrid"),
Diagram::xGrid).toInt();
const int y_grid = settings.value(QStringLiteral("diagrameditor/Ygrid"),
Diagram::yGrid).toInt();
const QPointF offset(stepOffset.x() * x_grid, stepOffset.y() * y_grid);
// Mirrors copy(), but does not touch the system clipboard: Ctrl+D
// should not clobber whatever the user last copied with Ctrl+C.
QDomDocument document = m_diagram->toXml(false, true);
DiagramContent pasted;
// No position argument -- see the function comment above for why
// the offset is not passed here.
m_diagram->fromXml(document, QPointF(), false, &pasted);
if (!pasted.count()) return;
const int movable = DiagramContent::Elements | DiagramContent::TextFields
| DiagramContent::Images | DiagramContent::Shapes
| DiagramContent::Tables | DiagramContent::TerminalStrip;
for (QGraphicsItem *item : pasted.items(movable))
item->setPos(item->pos() + offset);
m_diagram->clearSelection();
m_diagram->undoStack().push(new PasteDiagramCommand(m_diagram, pasted));
adjustSceneRect();
}
/**
Manage the events press click :
* click to add an independent text field
+1
View File
@@ -137,6 +137,7 @@ class DiagramView : public PaletteGraphicsView
void copy();
void paste(const QPointF & = QPointF(), QClipboard::Mode = QClipboard::Clipboard);
void pasteHere();
void duplicate(const QPoint &stepOffset);
void adjustSceneRect();
void updateWindowTitle();
void resetConductors();
+46
View File
@@ -52,6 +52,7 @@
#include "ui/bomexportdialog.h"
#include "ui/conductorcolortoolbutton.h"
#include "ui/diagrambgcolorbutton.h"
#include "ui/duplicateoffsetdialog.h"
#include "ui/jumptoelementdialog.h"
#include "ui/diagrampropertieseditordockwidget.h"
#include "ui/backupdialog.h"
@@ -374,6 +375,46 @@ void QETDiagramEditor::setUpActions()
new DiagramEventAddPaste(dv->diagram(), start_pos));
});
//Duplicate: copy the selection and place it at a configured,
//grid-step offset immediately -- no interactive follow-the-
//cursor step, unlike Ctrl+V above. That is deliberate (#991):
//the point of a duplicate shortcut is repeatable, unattended
//stamping (configure the offset once, then tap Ctrl+D to lay
//out a row), which an interactive placement would interrupt on
//every press.
m_duplicate = new QAction(QET::Icons::EditCopy, tr("Dupli&quer"), this);
ShortcutManager::instance().registerAction(m_duplicate, "diagrameditor.duplicate", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_D);
m_duplicate->setStatusTip(tr("Copie la sélection, décalée de l'espacement configuré", "status bar tip"));
connect(m_duplicate, &QAction::triggered, [this]() {
auto *dv = currentDiagramView();
if (!dv || !dv->diagram()) return;
//Ask the first time only -- every later press reuses whatever
//was confirmed then, so the shortcut can be tapped repeatedly
//without an interruption each time. m_configure_duplicate
//below is the deliberate way back into this dialog.
if (!DuplicateOffsetDialog::hasSavedStepOffset()) {
DuplicateOffsetDialog dialog(this);
if (dialog.exec() != QDialog::Accepted) return;
DuplicateOffsetDialog::saveStepOffset(dialog.stepOffset());
}
dv->duplicate(DuplicateOffsetDialog::savedStepOffset());
});
//Reopens the dialog above on demand, to change the spacing or
//direction a later Ctrl+D should use. Enabled unconditionally
//(see slot_updateComplexActions()): it only ever writes a
//setting, so it does not need a diagram open or anything
//selected the way m_duplicate itself does.
m_configure_duplicate = new QAction(tr("Configurer la duplication..."), this);
m_configure_duplicate->setStatusTip(tr("Choisir l'espacement et la direction utilisés par Dupliquer", "status bar tip"));
connect(m_configure_duplicate, &QAction::triggered, [this]() {
DuplicateOffsetDialog dialog(this);
if (dialog.exec() == QDialog::Accepted) {
DuplicateOffsetDialog::saveStepOffset(dialog.stepOffset());
}
});
//Reset conductor path
m_conductor_reset = new QAction(QET::Icons::ConductorSettings, tr("Réinitialiser les conducteurs"), this);
ShortcutManager::instance().registerAction(m_conductor_reset, "diagrameditor.conductor_reset", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_K);
@@ -894,6 +935,7 @@ void QETDiagramEditor::setUpToolBar()
main_tool_bar -> addAction(m_cut);
main_tool_bar -> addAction(m_copy);
main_tool_bar -> addAction(m_paste);
main_tool_bar -> addAction(m_duplicate);
main_tool_bar -> addSeparator();
main_tool_bar -> addAction(m_delete_selection);
main_tool_bar -> addAction(m_rotate_selection);
@@ -985,6 +1027,8 @@ void QETDiagramEditor::setUpMenu()
menu_edition -> addAction(m_cut);
menu_edition -> addAction(m_copy);
menu_edition -> addAction(m_paste);
menu_edition -> addAction(m_duplicate);
menu_edition -> addAction(m_configure_duplicate);
menu_edition -> addSeparator();
//The same actions the "Ajouter" toolbar holds. They were toolbar-only,
//which left them unreachable for anyone working without a mouse: a
@@ -1947,6 +1991,7 @@ void QETDiagramEditor::slot_updateComplexActions()
<< m_find_element
<< m_cut
<< m_copy
<< m_duplicate
<< m_delete_selection
<< m_rotate_selection
<< m_rotate_group_selection
@@ -1976,6 +2021,7 @@ void QETDiagramEditor::slot_updateComplexActions()
bool deletable_items = dc.hasDeletableItems();
m_cut -> setEnabled(!ro && copiable_items);
m_copy -> setEnabled(copiable_items);
m_duplicate -> setEnabled(!ro && copiable_items);
m_delete_selection -> setEnabled(!ro && deletable_items);
m_rotate_selection -> setEnabled(!ro && diagram_->canRotateSelection());
m_rotate_group_selection -> setEnabled(!ro && diagram_->canRotateSelection());
+2
View File
@@ -199,6 +199,8 @@ class QETDiagramEditor : public QETMainWindow
*undo, ///< Cancel the latest action
*redo, ///< Redo the latest cancelled operation
*m_paste, ///< Paste clipboard content on the current diagram
*m_duplicate, ///< Copy selection, offset by the configured step (#991)
*m_configure_duplicate, ///< Reopen the duplicate offset/direction dialog (#991)
*m_auto_conductor, ///< Enable/Disable the use of auto conductor
*m_auto_break_conductor, ///< Enable/Disable the use of auto break conductor
*m_draw_grid, ///< Switch the background grid display or not
+114
View File
@@ -0,0 +1,114 @@
/*
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 "duplicateoffsetdialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QSettings>
#include <QSpinBox>
#include <QVBoxLayout>
namespace {
// Stored as one integer per axis, in grid steps -- not pixels, so the
// remembered offset still makes sense if the grid size ever changes.
const QString kOffsetXKey = QStringLiteral("diagrameditor/duplicate_offset_x");
const QString kOffsetYKey = QStringLiteral("diagrameditor/duplicate_offset_y");
}
DuplicateOffsetDialog::DuplicateOffsetDialog(QWidget *parent) :
QDialog(parent)
{
setWindowTitle(tr("Dupliquer"));
auto *form = new QFormLayout;
m_spacing = new QSpinBox(this);
m_spacing->setRange(1, 1000);
m_spacing->setSuffix(tr(" pas de grille"));
form->addRow(tr("Espacement :"), m_spacing);
m_direction = new QComboBox(this);
// Order matches the Direction enum, so currentIndex() can be used
// directly wherever Direction is needed.
m_direction->addItem(tr("Haut"));
m_direction->addItem(tr("Bas"));
m_direction->addItem(tr("Gauche"));
m_direction->addItem(tr("Droite"));
form->addRow(tr("Direction :"), m_direction);
const QPoint saved = savedStepOffset();
// The saved value is a signed (dx, dy) pair, not itself a
// spacing+direction pair, so it has to be decomposed back into the
// two the dialog shows. Exactly one axis is ever non-zero (see
// stepOffset()), so whichever one is picks the direction; a value
// that somehow has neither (only possible if QSettings was hand-
// edited) falls back to the same default stepOffset() would.
int spacing = 1;
Direction direction = Right;
if (saved.x() > 0) { direction = Right; spacing = saved.x(); }
else if (saved.x() < 0) { direction = Left; spacing = -saved.x(); }
else if (saved.y() > 0) { direction = Down; spacing = saved.y(); }
else if (saved.y() < 0) { direction = Up; spacing = -saved.y(); }
m_spacing->setValue(spacing);
m_direction->setCurrentIndex(static_cast<int>(direction));
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addLayout(form);
layout->addWidget(buttons);
}
QPoint DuplicateOffsetDialog::stepOffset() const
{
const int spacing = m_spacing->value();
switch (static_cast<Direction>(m_direction->currentIndex())) {
case Up: return QPoint(0, -spacing);
case Down: return QPoint(0, spacing);
case Left: return QPoint(-spacing, 0);
case Right: return QPoint(spacing, 0);
}
return QPoint(spacing, 0); // unreachable; keeps -Wreturn-type quiet
}
QPoint DuplicateOffsetDialog::savedStepOffset()
{
QSettings settings;
if (!hasSavedStepOffset()) {
return QPoint(1, 0); // default: one grid step to the right
}
return QPoint(settings.value(kOffsetXKey).toInt(),
settings.value(kOffsetYKey).toInt());
}
void DuplicateOffsetDialog::saveStepOffset(const QPoint &steps)
{
QSettings settings;
settings.setValue(kOffsetXKey, steps.x());
settings.setValue(kOffsetYKey, steps.y());
}
bool DuplicateOffsetDialog::hasSavedStepOffset()
{
QSettings settings;
return settings.contains(kOffsetXKey) && settings.contains(kOffsetYKey);
}
+65
View File
@@ -0,0 +1,65 @@
/*
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 DUPLICATEOFFSETDIALOG_H
#define DUPLICATEOFFSETDIALOG_H
#include <QDialog>
#include <QPoint>
class QSpinBox;
class QComboBox;
/**
@brief The DuplicateOffsetDialog class
Asks how far, and in which of the four cardinal directions, Ctrl+D
(DiagramView::duplicate(), bugtracker #991) should offset a copy from
its source. Shown once, then remembered: the answer is stored in
QSettings and reused by every later Ctrl+D press without asking
again, until this dialog is reopened deliberately.
*/
class DuplicateOffsetDialog : public QDialog
{
Q_OBJECT
public:
enum Direction { Up, Down, Left, Right };
explicit DuplicateOffsetDialog(QWidget *parent = nullptr);
/// The offset in grid steps, positive along X to the right
/// and positive along Y downward -- QET's own scene axes,
/// matching the sign convention setPos() already uses
/// everywhere else in this codebase.
QPoint stepOffset() const;
/// Reads the last-confirmed spacing/direction from QSettings,
/// or the default (1 step, right) if none was ever set.
static QPoint savedStepOffset();
/// Writes @p steps to QSettings, in the same X/Y convention
/// as stepOffset().
static void saveStepOffset(const QPoint &steps);
/// Whether a direction/spacing has already been confirmed
/// once, i.e. whether Ctrl+D can skip the dialog.
static bool hasSavedStepOffset();
private:
QSpinBox *m_spacing = nullptr;
QComboBox *m_direction = nullptr;
};
#endif // DUPLICATEOFFSETDIALOG_H