Compare commits

...

3 Commits

Author SHA1 Message Date
Laurent Trinques 0258d1a74f Merge pull request #917 from ispyisail/fix/913-paste-under-cursor
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 1m43s
Paste under the cursor, and restore the folio context menu after a paste (#913)
2026-09-18 10:26:00 +02:00
ispyisail dd0c194a3c Paste under the cursor, and give the context menu back (#913)
Two fixes in the placement tool Ctrl+V starts.

Paste appeared on top of the original, not under the cursor. 55c2c0df9
added the placement tool precisely so the copy would not land invisibly
on top of what was copied, but 53a0f07ca then warped the pointer to the
group's grid-snapped origin -- which is the original's position -- so the
copy reappeared exactly over the original until the mouse was moved. The
start_pos the caller computes from the cursor was left unread: three
mentions in the file, all declaration or comment. Reported on #913,
where it reads as Ctrl+V pasting in place.

Move the group to the cursor instead of the cursor to the group. Both put
the copy under the pointer; only one of them takes the pointer away from
where the user put it. start_pos is honoured, the items are translated
once at construction, their conductors re-routed before anything is
drawn, and the movement baseline is set there too rather than waiting for
the first mouse move.

That made the baseline sentinel matter, so it is now the
m_baseline_captured flag the header already declared and nothing used,
rather than m_initial_cursor.isNull() -- which cannot tell "not set yet"
from a baseline that is legitimately scene (0,0).

Separately: one Ctrl+V killed the folio's right-click menu for the rest
of the session. init() sets Qt::NoContextMenu so a right click cancels
the placement instead of opening a menu over it, and nothing ever set it
back, taking "Coller ici", "Collage multiple", the folio properties and
add/remove column/row with it. Every other DiagramEvent* class restores
the policy in its destructor; this one did not. Confirmed against an
unmodified master build: the menu opens before a paste and not after one.
It matters here because "use the right-click menu instead" is the answer
people are given when Ctrl+V does not place where they wanted.

Verified on a built binary driven through a virtual X display, against
examples/741.qet and convertisseur.qet:

- the pointer does not move across Ctrl+V (1300,870 before and after;
  on master it jumps to the original at 798,455), and the copy is under it
- a multi-element selection keeps its layout and its conductors re-route
- Escape and right-click both cancel, leaving nothing behind and nothing
  on the undo stack; click and Return both commit; one undo removes the
  whole paste and redo restores it
- with the pointer outside the viewport the copy lands at the viewport
  centre, visible, and follows correctly once the mouse enters
- pasting into a different folio from the one copied from works
- the context menu opens before a paste, after a cancelled one, and after
  a committed one, with "Coller ici" present

ctest 9/9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:57:59 +12:00
Laurent Trinques 3ba7de3284 CI: fix skip SignPath signing until the API token is configured 2026-09-18 08:33:45 +02:00
3 changed files with 93 additions and 30 deletions
+23
View File
@@ -292,9 +292,15 @@ jobs:
# check: forks never have the SignPath secrets, and a fork-originated # check: forks never have the SignPath secrets, and a fork-originated
# PR/run must never attempt a signing request. # PR/run must never attempt a signing request.
# (cf. DieterMayerOSS:fix/msi-signing-fork-guard, d3f60c88) # (cf. DieterMayerOSS:fix/msi-signing-fork-guard, d3f60c88)
# continue-on-error: SignPath's certificate is still pending validation
# (Sept 2026) — the signing request currently fails with a 500 on the
# SignPath side. Kept non-blocking so the nightly MSI still ships
# (unsigned) while the certificate is pending. Remove
# continue-on-error once the certificate is confirmed active.
- name: Sign MSI via SignPath - name: Sign MSI via SignPath
id: sign id: sign
if: github.repository == 'qelectrotech/qelectrotech-source-mirror' && env.SIGNPATH_API_TOKEN != '' if: github.repository == 'qelectrotech/qelectrotech-source-mirror' && env.SIGNPATH_API_TOKEN != ''
continue-on-error: true
uses: signpath/github-action-submit-signing-request@v2 uses: signpath/github-action-submit-signing-request@v2
with: with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }} api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -306,7 +312,22 @@ jobs:
wait-for-completion: true wait-for-completion: true
output-artifact-directory: 'dist\' output-artifact-directory: 'dist\'
# If signing succeeded, SignPath already overwrote dist\*.msi with the
# signed copy (output-artifact-directory above) — nothing to do here.
# If it failed/was skipped, dist\*.msi is still the unsigned MSI from
# the "Build MSI" step, so the rest of the pipeline just ships that.
- name: Report signing status
if: always()
shell: pwsh
run: |
if ("${{ steps.sign.outcome }}" -eq "success") {
Write-Host "MSI signed successfully via SignPath."
} else {
Write-Warning "MSI signing skipped or failed (outcome: ${{ steps.sign.outcome }}) — shipping UNSIGNED MSI. Likely cause: SignPath certificate still pending validation."
}
- name: Upload signed MSI artifact - name: Upload signed MSI artifact
if: always()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: qelectrotech-windows-msi-${{ matrix.flavor }} name: qelectrotech-windows-msi-${{ matrix.flavor }}
@@ -315,6 +336,7 @@ jobs:
if-no-files-found: error if-no-files-found: error
- name: Delete old nightly .msi asset - name: Delete old nightly .msi asset
if: always()
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
@@ -328,6 +350,7 @@ jobs:
shell: pwsh shell: pwsh
- name: Upload MSI to nightly release - name: Upload MSI to nightly release
if: always()
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
with: with:
tag_name: nightly tag_name: nightly
+60 -25
View File
@@ -59,9 +59,8 @@
if (movable.isEmpty()) return; if (movable.isEmpty()) return;
//Compute the top-left of all items' positions (not bounding //Compute the top-left of all items' positions (not bounding
//rects) and snap to grid — used only for the initial cursor //rects) and snap to grid: this is the point that gets placed
//warp. Items stay at their original XML positions; //under the cursor, and the baseline moveTo() measures from.
//moveTo() handles grid-snapped movement via deltas.
QPointF top_left; QPointF top_left;
bool first = true; bool first = true;
for (auto *item : movable) { for (auto *item : movable) {
@@ -79,17 +78,42 @@
Diagram::xGrid).toInt(); Diagram::xGrid).toInt();
const int yGrid = settings.value(QStringLiteral("diagrameditor/Ygrid"), const int yGrid = settings.value(QStringLiteral("diagrameditor/Ygrid"),
Diagram::yGrid).toInt(); Diagram::yGrid).toInt();
const QPointF grid_origin( const auto snapGrid = [xGrid, yGrid](const QPointF &p) -> QPointF {
qRound(top_left.x() / xGrid) * xGrid, return QPointF(
qRound(top_left.y() / yGrid) * yGrid); qRound(p.x() / xGrid) * xGrid,
qRound(p.y() / yGrid) * yGrid);
};
const QPointF grid_origin = snapGrid(top_left);
//Store each item's position. moveTo() applies a grid-snapped //Move the group to the cursor, rather than the cursor to the
//delta from the baseline, so items preserve their layout and //group. Both put the copy under the pointer, but warping the
//move in whole grid steps. //pointer also drags it back to the original's position, so the
//copy appears exactly on top of what was copied until the mouse
//is moved -- which is the thing pasting under the cursor was
//meant to avoid (issue #913). Taking the pointer away from
//where the user put it is also its own surprise.
m_group_origin = snapGrid(start_pos);
const QPointF offset = m_group_origin - grid_origin;
//Store each item's position after the move. moveTo() applies a
//grid-snapped delta from the baseline to these, so items
//preserve their layout and move in whole grid steps.
for (auto *item : movable) { for (auto *item : movable) {
item->setPos(item->pos() + offset);
m_relative_pos.insert(item, item->pos()); m_relative_pos.insert(item, item->pos());
} }
m_group_origin = grid_origin;
//The conductors were laid out against the old terminal
//positions, so re-route them before anything is drawn.
const QList<Conductor *> conductors = m_content.conductors(DiagramContent::AnyConductor);
for (auto *conductor : conductors) {
conductor->updatePath();
}
//The baseline is known now, so moveTo() does not have to
//capture one from the first mouse movement.
m_initial_cursor = m_group_origin;
m_baseline_captured = true;
m_diagram->clearSelection(); m_diagram->clearSelection();
for (auto *item : movable) { for (auto *item : movable) {
@@ -101,11 +125,6 @@
if (const auto qde = QETApp::diagramEditorAncestorOf(view)) { if (const auto qde = QETApp::diagramEditorAncestorOf(view)) {
m_status_bar = qde->statusBar(); m_status_bar = qde->statusBar();
} }
//Warp the cursor close to the group origin so the
//first mouseMoveEvent captures the correct baseline.
const QPoint view_pos = view->mapFromScene(m_group_origin);
const QPoint global_pos = view->viewport()->mapToGlobal(view_pos);
QCursor::setPos(global_pos);
} }
} }
showHint(); showHint();
@@ -129,6 +148,20 @@ DiagramEventAddPaste::~DiagramEventAddPaste()
if (m_status_bar) { if (m_status_bar) {
m_status_bar->clearMessage(); m_status_bar->clearMessage();
} }
//Give the context menu back. init() turned it off so a right
//click would cancel the placement instead of opening a menu over
//it, and nothing turned it on again: one Ctrl+V left the folio's
//right-click menu dead for the rest of the session, taking
//"Coller ici", "Collage multiple" and the folio properties with
//it. Every other DiagramEvent* class restores it here; this one
//did not.
if (m_diagram) {
const auto views = m_diagram->views();
for (auto *view : views) {
view->setContextMenuPolicy(Qt::DefaultContextMenu);
}
}
} }
/** /**
@@ -166,9 +199,10 @@ void DiagramEventAddPaste::showHint()
/** /**
@brief DiagramEventAddPaste::moveTo @brief DiagramEventAddPaste::moveTo
Compute a grid-snapped delta from the initial cursor position and Compute a grid-snapped delta from the initial cursor position and
apply it to every item's grid-shifted position. This keeps all apply it to every item's stored position. Working from a delta
items exactly on grid points regardless of modifier keys or against a fixed baseline, rather than from the previous position,
sub-pixel cursor-warp rounding. keeps all items exactly on grid points regardless of modifier keys
and stops rounding accumulating over a long drag.
*/ */
void DiagramEventAddPaste::moveTo(const QPointF &scene_pos) void DiagramEventAddPaste::moveTo(const QPointF &scene_pos)
{ {
@@ -184,14 +218,15 @@ void DiagramEventAddPaste::moveTo(const QPointF &scene_pos)
qRound(p.y() / yGrid) * yGrid); qRound(p.y() / yGrid) * yGrid);
}; };
//On the very first call, record the actual grid-snapped //The constructor normally sets the baseline, having just put the
//cursor position as baseline. The cursor warp in the //group there. This covers the case where it could not -- no view
//constructor goes through integer rounding (mapFromScene → //to map through -- by taking the first cursor position instead.
//QPoint) so the real position may differ slightly from //Tested with m_baseline_captured rather than
//m_initial_cursor. Using the actual scene position avoids //m_initial_cursor.isNull(), which silently re-baselines when the
//a one-grid-unit jump on the first mouse movement. //baseline is legitimately scene (0,0).
if (m_initial_cursor.isNull()) { if (!m_baseline_captured) {
m_initial_cursor = snapGrid(scene_pos); m_initial_cursor = snapGrid(scene_pos);
m_baseline_captured = true;
return; return;
} }
+10 -5
View File
@@ -79,13 +79,18 @@ class DiagramEventAddPaste : public DiagramEventInterface
///Each movable item's position relative to the group's top left, ///Each movable item's position relative to the group's top left,
///taken once so repeated moves cannot accumulate rounding drift. ///taken once so repeated moves cannot accumulate rounding drift.
QHash<QGraphicsItem *, QPointF> m_relative_pos; QHash<QGraphicsItem *, QPointF> m_relative_pos;
///Top-left corner of the bounding rect of all movable items, ///Where the group's grid-snapped top left was put when the paste
///in scene coordinates, captured when the paste starts. ///started, in scene coordinates -- the cursor, so the copy
///appears under the pointer rather than on top of what was
///copied.
QPointF m_group_origin; QPointF m_group_origin;
///Cursor position (scene coords) at the moment the paste starts, ///Cursor position (scene coords) the delta-based movement in
///so delta-based movement can compute offsets from the initial point. ///moveTo() measures from. Equal to m_group_origin, since the
///group is placed at the cursor.
QPointF m_initial_cursor; QPointF m_initial_cursor;
///Set to true once the first moveTo() captures the real cursor position. ///Whether m_initial_cursor holds a usable baseline. A flag rather
///than testing m_initial_cursor.isNull(), which cannot tell "not
///set yet" from a baseline that is legitimately scene (0,0).
bool m_baseline_captured{false}; bool m_baseline_captured{false};
QPointer<QStatusBar> m_status_bar; QPointer<QStatusBar> m_status_bar;
bool m_finished{false}; bool m_finished{false};