diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 738764732..b40fd2344 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -721,6 +721,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/backupdialog.h ${QET_DIR}/sources/ui/dialogwaiting.cpp ${QET_DIR}/sources/ui/dialogwaiting.h + ${QET_DIR}/sources/ui/duplicateoffsetdialog.cpp + ${QET_DIR}/sources/ui/duplicateoffsetdialog.h ${QET_DIR}/sources/ui/dynamicelementtextitemeditor.cpp ${QET_DIR}/sources/ui/dynamicelementtextitemeditor.h ${QET_DIR}/sources/ui/dynamicelementtextmodel.cpp diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index caafeb07a..7cac5198f 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -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 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 diff --git a/sources/diagramview.h b/sources/diagramview.h index 3f1e6a812..c35544c58 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -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(); diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index cd29dd096..b7dd3aaa7 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -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()); diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 4ca557eac..71ad56244 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -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 diff --git a/sources/ui/duplicateoffsetdialog.cpp b/sources/ui/duplicateoffsetdialog.cpp new file mode 100644 index 000000000..906bc97cd --- /dev/null +++ b/sources/ui/duplicateoffsetdialog.cpp @@ -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 . +*/ +#include "duplicateoffsetdialog.h" + +#include +#include +#include +#include +#include +#include + +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(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(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); +} diff --git a/sources/ui/duplicateoffsetdialog.h b/sources/ui/duplicateoffsetdialog.h new file mode 100644 index 000000000..9a7085ddb --- /dev/null +++ b/sources/ui/duplicateoffsetdialog.h @@ -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 . +*/ +#ifndef DUPLICATEOFFSETDIALOG_H +#define DUPLICATEOFFSETDIALOG_H + +#include +#include + +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