Compare commits

...

13 Commits

Author SHA1 Message Date
Laurent Trinques fd38f55724 Merge pull request #874 from ispyisail/feature/diagram-selection-shortcuts-v2
Add Tab selection cycling and select-all conductors/text fields
2026-09-15 10:20:28 +02:00
Laurent Trinques 85ed1b8a2a Merge pull request #878 from ispyisail/feature/paste-follows-cursor
Paste under the cursor, and let it be positioned before it lands
2026-09-15 10:16:06 +02:00
Laurent Trinques 607eb4b1ea Merge pull request #871 from ispyisail/test/ipc-open-forwarding-regression
Add a regression test for the forwarded-file use-after-free
2026-09-15 09:55:34 +02:00
Laurent Trinques 1e124450f2 Merge pull request #875 from ispyisail/fix/keyboard-reachable-drawing-tools
Put the drawing tools in a menu so they can be used without a mouse
2026-09-15 09:51:23 +02:00
Laurent Trinques 4d70fcf35c Merge pull request #877 from ispyisail/fix/f10-opens-menubar
Open the menu bar on F10, the key people expect
2026-09-15 09:46:24 +02:00
ispyisail 55c2c0df9d Paste under the cursor and let it be positioned before it lands
Ctrl+V pasted in place, which put the copy exactly on top of the original.
Nothing appeared to happen: the only clue was a doubled outline, and the
copy had to be dragged off the original to be seen at all. The cursor was
ignored entirely.

Ctrl+V now starts a placement. The items appear under the cursor and follow
it until a left click or Return drops them; Escape or a right click takes
them away again. That is the same interaction as placing a new element, so
paste behaves like every other way of putting something on a folio, and the
copy lands where the user is looking.

Implemented as a DiagramEventInterface beside the existing add-element and
add-macro tools. The pasted items are the real ones from the start rather
than a preview: Diagram::fromXml creates them exactly as before, this class
moves them, and PasteDiagramCommand is pushed only once they are dropped.
PasteDiagramCommand's first redo() deliberately does not add items to the
scene -- it assumes fromXml already did -- so pushing it on commit adopts
them rather than duplicating them. One copy of the paste logic, and a
cancelled paste leaves nothing on the undo stack.

Conductors are not moved directly; they are drawn from their terminals and
follow the elements they attach to. On cancel they are removed before the
elements, so none is left in the scene holding a pointer to a freed
terminal.

Verified by counting elements in the saved file rather than by eye:
56 to start, 56 after paste-then-Escape, 57 after paste-then-drop, and 56
again after undo. Save determinism run against this build: pass, no
regressions against baseline. Tests 5/5 on Qt 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:07:15 +12:00
ispyisail b94b244919 Correct the comment: say what was measured, not what was assumed
Two claims in the previous comment were wrong.

"Qt implements F10 on Windows but not on X11" was inference I cannot test
here. What is measured is narrower and enough: QMenuBar given Key_F10
directly leaves it unaccepted, and sent to the window the key never reaches
the menu bar at all, because a key press goes to the focused child widget.

"&Édition takes É, which is not on a UK or US keyboard" was wrong outright.
It came from running an uninstalled binary, which cannot find its .qm files
and falls back to the French source strings. With translations loaded the
menus read File, Edit, Project, Display, Settings, Windows, Help, and Alt+E
opens Edit.

The comment now also says plainly that this is convenience rather than
access: Alt tap focuses the bar and Alt with a letter opens a menu, both
verified working, so the menus were already reachable without a mouse. F10
is the key people reach for out of habit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GtMZqGEiUMvDBqcFvVG2vb
2026-09-15 15:51:24 +12:00
ispyisail bd6bed8d61 Open the menu bar on F10, and add a test that can answer whether it works
F10 opens the menu bar in most applications and is the usual way to reach
the menus without a mouse. Qt provides this on Windows but not on X11, so on
Linux the key did nothing and the press fell through to whichever widget had
focus. It matters more here than it might elsewhere: "&Édition" takes É for
its own letter, which is not on a UK or US keyboard, so that menu has no
direct Alt route at all.

A window-context QShortcut rather than a key handler -- key presses go to
the focused child widget, so a keyPressEvent() on the window would never see
F10 while the canvas or a panel has focus.

tests/qttest/tst_menubarkeyboard.cpp covers three things: that Alt and a
letter opens a menu (the control), that plain F10 does nothing in Qt itself
(which is why the shortcut exists, and which will fail loudly if a future Qt
starts handling it), and that the shortcut mechanism opens the bar.

It uses QTest instead of driving a real X server for a specific reason.
xdotool on Xvfb delivers every function key with Alt held: a Qt key logger
shows Key_F10 arriving with modifiers == Qt::AltModifier. --clearmodifiers,
keydown/keyup pairs, --window targeting and flattening the keycode with
xmodmap all made no difference. Two rounds of GUI automation therefore gave
confident, wrong answers about F10 -- first that it was broken, then that
this very fix did not work. QTest posts the event straight to the widget, so
the key arrives as written.

What the test does not cover, since initCommonActions() calls
QETApp::instance() and constructing that pulls in the whole application: it
repeats the wiring rather than driving QETMainWindow. Confirming the real
window responds still needs someone to press F10 in a running QElectroTech.

Verified by breaking it: bound to F11 instead, the test fails. Qt 5 and Qt 6
both build clean, 6/6 tests on each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:44:59 +12:00
ispyisail 6a2b3973bc Put the drawing tools in a menu so they can be reached without a mouse
The nine "Ajouter" actions -- text field, image, PDF, line, rectangle,
ellipse, polyline, curve, terminal strip -- were only ever added to
m_add_item_tool_bar, and the automatic conductor break only to
diagram_tool_bar. None carried a shortcut. A toolbar button has no key,
so someone working without a mouse could not add anything at all to a
folio.

They now appear in an "Ajouter" submenu under Édition, and the conductor
break beside m_auto_conductor in Projet, the setting it pairs with. The
actions themselves are untouched: a QAction can sit in a menu and a
toolbar at once, which is what m_depth_action_group -- created a few lines
away, and added to both its toolbar and menu_edition -- has always done.
That contrast is why this reads as an oversight rather than a decision.

Verified by driving the menus with the keyboard alone under Xvfb: Alt+F
opens the File menu, Down then Right crosses to Édition, and Right again
opens the Ajouter submenu with all eight actions this build compiles
(add_pdf is behind QET_HAS_QTPDF and absent on Qt 5).

Found with tools/keyboard-audit in the qelectrotech-docker harness, which
reports these ten and now reports none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:45:51 +12:00
ispyisail d9db6e59e7 Let Escape step back out of the folio, so Tab cannot trap keyboard users
Tab cycles the folio's items, which means focusNextPrevChild() has to
refuse the usual focus traversal. On its own that leaves someone working
without a mouse able to reach the drawing area and never leave it -- the
exact person the Tab cycling was added for.

Escape now steps back out in two stages: it drops the selection first,
then hands focus to the next widget. The one-shot m_releasing_focus flag
is what lets that second Escape through the override.

Verified under Xvfb: with an item selected, Escape clears it (193k pixels
change); a second Escape changes nothing visually; a Tab after that moves
widget focus in the toolbar (306 pixels) instead of selecting on the
canvas, which is the behaviour of a view that no longer holds focus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:32:42 +12:00
ispyisail 22469813fe Register the two new selection actions with ShortcutManager
This branch was cut on 31 July, one day before ShortcutManager landed
in 5275fb44f, so the two actions it adds were written before the
convention existed and are the only members of the selection group not
registered: select_all, select_nothing and select_invert all are.

Without this they never appear in the shortcuts configuration page, so
a user cannot bind a key to either of them.

Registered with an empty default sequence. They are menu actions and
neither has an obvious default worth claiming; the point of registering
them is that a user can bind one if they want. ShortcutManager stores
an empty default without setting a shortcut, and the conflict checker
already skips empty sequences.

Master merged in first, because ShortcutManager does not exist at this
branch's original base.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:32:50 +12:00
ispyisail 88823ea35f Diagram: Tab/Shift+Tab item-selection cycling + select-all-conductors/text-fields (#574)
Implements the second pillar of #574: keyboard-driven selection on the
diagram canvas.

Tab / Shift+Tab select the next / previous item on the current
diagram, cycling through items() (z-order) and wrapping at either
end. If nothing is selected, Tab selects the first item and
Shift+Tab the last. Skipped while a text item has focus, for the
same reason arrow-key movement already guards on !focusItem().
Candidates use the same "what counts as a real selectable diagram
item" filter (QetGraphicsItem / DiagramTextItem / Conductor) already
established by Diagram::invertSelection(), so the cycling order
always matches what a user could reach by clicking.

Getting Tab to actually reach the scene needed two separate fixes,
each independently discovered by empirical testing rather than
assumption:

- QWidget (DiagramView) intercepts Tab/Backtab for widget focus-chain
  traversal before generating a key event at all. Overriding
  DiagramView::focusNextPrevChild() to return false disables that.
- QGraphicsScene (Diagram) has its own, separate item-focus-chain
  traversal, checked before keyPressEvent() is ever reached. The
  obvious fix -- overriding Diagram::focusNextPrevChild() the same
  way -- silently does nothing on Qt 5, because
  QGraphicsScene::focusNextPrevChild() only becomes virtual in Qt 6
  (guarded by the QT6_VIRTUAL macro); a compile error surfaced this
  immediately when attempted directly, rather than shipping a fix
  that worked on Qt 6 and silently no-opped on Qt 5. Intercepting
  QEvent::KeyPress in Diagram::event() instead is virtual on every Qt
  version and sidesteps the scene's internal traversal entirely.

Also adds Diagram::selectAllConductors() / selectAllTextFields(),
wired up as two new actions in the existing select_all /
select_nothing / select_invert action group in
qetdiagrameditor.cpp, so they appear in the Edit menu and go through
the same QAction -> data() -> selectGroupTriggered() dispatch as the
existing selection commands.

Verified end-to-end in a real running session (Xvfb + xdotool) with
a multi-transistor schematic: Tab/Shift+Tab correctly move a single
selection forward/backward through elements and text fields
(confirmed via the properties panel updating to each new item and
the visual selection box moving on canvas); Tab/Shift+Tab from no
selection correctly select the first/last item; "Select all
conductors" and "Select all text fields" each correctly select every
matching item and deselect everything else.

See discussion #574.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:32:50 +12:00
ispyisail 9363e9bc2e Add a regression test for the forwarded-file use-after-free
Covers the crash fixed in #868: a file forwarded from a second instance was
opened inside SingleApplication's socket handler, so the backup prompt's
nested event loop ran while that handler was still on the stack.

Run by hand; no build system or CI changes.

    tests/ipc-regression/run.sh --binary build/qelectrotech

Validated in both directions on Qt 6.10.2: ceda1e082 (before the fix) crashes
3 times in 3 with exit 139, 199444b6 (after) survives 3 times in 3.

Three requirements are not obvious and are documented in the script:

- Qt 6 only. An unfixed Qt 5 build survives every attempt, so the script
  refuses to run on a Qt 5 binary rather than report a pass that cannot fail.
- A Debug build. The same unfixed commit survives every attempt built
  -O3 -DNDEBUG; whether a use-after-free faults depends on what the allocator
  does with the freed block.
- Dismissing the backup prompt is the step that triggers it. Left open, the
  stack never unwinds and nothing fails, which is why the bug was twice
  reported as not reproducible.

The test runs in its own sandbox on its own X display, works on a copy of the
project so backup files do not land in examples/, and cleans up after itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:09:16 +12:00
14 changed files with 1133 additions and 3 deletions
+2
View File
@@ -330,6 +330,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/diagramevent/diagrameventinterface.h
${QET_DIR}/sources/diagramevent/diagrameventaddmacro.cpp
${QET_DIR}/sources/diagramevent/diagrameventaddmacro.h
${QET_DIR}/sources/diagramevent/diagrameventaddpaste.cpp
${QET_DIR}/sources/diagramevent/diagrameventaddpaste.h
${QET_DIR}/sources/dvevent/dveventinterface.cpp
${QET_DIR}/sources/dvevent/dveventinterface.h
+107
View File
@@ -450,6 +450,34 @@ void Diagram::wheelEvent(QGraphicsSceneWheelEvent *event)
QGraphicsScene::wheelEvent(event);
}
/**
@brief Diagram::event
QGraphicsScene has its own Tab/Shift+Tab item-focus-chain traversal
(mirroring QWidget's), checked before keyPressEvent() is ever reached:
by default it would silently consume Tab/Backtab to move focus among
the scene's own focusable items. Intercepting the key press here,
ahead of that, is the only way to reliably override it: unlike
QWidget::focusNextPrevChild(), QGraphicsScene::focusNextPrevChild() is
only virtual starting in Qt 6 (guarded by the QT6_VIRTUAL macro), so a
Diagram:: override of it would silently do nothing on a Qt 5 build.
@param event
*/
bool Diagram::event(QEvent *event)
{
if (event->type() == QEvent::KeyPress) {
auto *key_event = static_cast<QKeyEvent *>(event);
if ((key_event->key() == Qt::Key_Tab || key_event->key() == Qt::Key_Backtab)
&& !isReadOnly() && !focusItem()) {
bool forward = key_event->key() == Qt::Key_Tab
&& !(key_event->modifiers() & Qt::ShiftModifier);
selectNextItem(forward);
event->accept();
return true;
}
}
return QGraphicsScene::event(event);
}
/**
@brief Diagram::keyPressEvent
This event is managed by diagram event interface if any.
@@ -1902,6 +1930,85 @@ void Diagram::invertSelection()
emit selectionChanged();
}
/**
@brief Diagram::selectAllConductors
Select every conductor on this diagram, deselecting anything else.
*/
void Diagram::selectAllConductors()
{
if (items().isEmpty()) return;
blockSignals(true);
for (auto item : items()) {
item -> setSelected(dynamic_cast<Conductor *>(item) != nullptr);
}
blockSignals(false);
emit selectionChanged();
}
/**
@brief Diagram::selectAllTextFields
Select every text field on this diagram (independent/static text,
conductor labels, and dynamic element texts), deselecting anything else.
*/
void Diagram::selectAllTextFields()
{
if (items().isEmpty()) return;
blockSignals(true);
for (auto item : items()) {
item -> setSelected(dynamic_cast<DiagramTextItem *>(item) != nullptr);
}
blockSignals(false);
emit selectionChanged();
}
/**
@brief Diagram::selectNextItem
Select the next (or, if @a forward is false, the previous) selectable
item on this diagram, cycling through items() (z-order) and wrapping
around at either end. If nothing is currently selected, selects the
first (or last) item. Uses the same "what counts as a real selectable
diagram item" filter as invertSelection(), so the candidate list and
its order always match what the user could reach by clicking.
@param forward true to select the next item, false for the previous one
*/
void Diagram::selectNextItem(bool forward)
{
QList<QGraphicsItem *> candidates;
for (auto item : items()) {
if (dynamic_cast<QetGraphicsItem *>(item) ||
dynamic_cast<DiagramTextItem *>(item) ||
dynamic_cast<Conductor *>(item)) {
candidates << item;
}
}
if (candidates.isEmpty())
return;
int current_index = -1;
for (int i = 0; i < candidates.size(); ++i) {
if (candidates.at(i) -> isSelected()) {
current_index = i;
break;
}
}
int next_index;
if (current_index == -1) {
next_index = forward ? 0 : candidates.size() - 1;
} else {
next_index = forward
? (current_index + 1) % candidates.size()
: (current_index - 1 + candidates.size()) % candidates.size();
}
clearSelection();
QGraphicsItem *next_item = candidates.at(next_index);
next_item -> setSelected(true);
next_item -> ensureVisible();
}
/**
@brief Diagram::insertFolioSeqHash
This class inserts a stringlist containing all
+5
View File
@@ -150,7 +150,10 @@ class Diagram : public QGraphicsScene
void wheelEvent (QGraphicsSceneWheelEvent *event) override;
void keyPressEvent (QKeyEvent *event) override;
void keyReleaseEvent (QKeyEvent *) override;
bool event(QEvent *event) override;
private:
void selectNextItem(bool forward);
public:
void correctTextPos(Element* elmt);
@@ -287,6 +290,8 @@ class Diagram : public QGraphicsScene
void selectAll();
void deselectAll();
void invertSelection();
void selectAllConductors();
void selectAllTextFields();
signals:
void showDiagram (Diagram *);
@@ -0,0 +1,241 @@
/*
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 "diagrameventaddpaste.h"
#include "../diagram.h"
#include "../diagramcommands.h"
#include "../qetapp.h"
#include "../qetdiagrameditor.h"
#include "../qetgraphicsitem/conductor.h"
#include <QApplication>
#include <QClipboard>
#include <QGraphicsSceneMouseEvent>
#include <QKeyEvent>
#include <QStatusBar>
/**
@brief DiagramEventAddPaste::DiagramEventAddPaste
@param diagram : diagram to paste into
@param start_pos : where the pasted items first appear, in scene
coordinates -- normally the cursor
*/
DiagramEventAddPaste::DiagramEventAddPaste(Diagram *diagram, const QPointF &start_pos) :
DiagramEventInterface(diagram)
{
//DiagramEventInterface::init() is called by Diagram::setEventInterface
//only when it is replacing an earlier interface, so call it here as
//DiagramEventAddMacro does.
init();
const QString clipboard_text = QApplication::clipboard()->text();
if (clipboard_text.isEmpty()) return;
QDomDocument document_xml;
if (!document_xml.setContent(clipboard_text)) return;
m_diagram->fromXml(document_xml, Diagram::snapToGrid(start_pos), false, &m_content);
if (!m_content.count()) return;
//Remember where each item sits relative to the group's top left, so a
//move is one assignment per item rather than an accumulated delta.
QRectF group_rect;
const QList<QGraphicsItem *> movable = m_content.items(MovableItems);
for (auto *item : movable) {
group_rect = group_rect.united(item->mapToScene(item->boundingRect()).boundingRect());
}
const QPointF top_left = group_rect.topLeft();
for (auto *item : movable) {
m_relative_pos.insert(item, item->pos() - top_left);
}
m_diagram->clearSelection();
for (auto *item : movable) {
item->setSelected(true);
}
if (!m_diagram->views().isEmpty()) {
if (const auto qde = QETApp::diagramEditorAncestorOf(m_diagram->views().at(0))) {
m_status_bar = qde->statusBar();
}
}
showHint();
m_running = true;
}
/**
@brief DiagramEventAddPaste::~DiagramEventAddPaste
If the placement never finished -- the editor closed, or another tool took
over -- the items are still on the folio with nothing on the undo stack to
account for them, so take them away.
*/
DiagramEventAddPaste::~DiagramEventAddPaste()
{
if (!m_finished && m_diagram) {
cancel();
}
if (m_status_bar) {
m_status_bar->clearMessage();
}
}
/**
@brief DiagramEventAddPaste::clipboardHasDiagram
@return true if the clipboard holds a diagram fragment
*/
bool DiagramEventAddPaste::clipboardHasDiagram()
{
return Diagram::clipboardMayContainDiagram();
}
/**
@brief DiagramEventAddPaste::init
Suppress the context menu while placing, so a right click can cancel
instead of opening a menu over the items being positioned.
*/
void DiagramEventAddPaste::init()
{
if (!m_diagram) return;
const auto views = m_diagram->views();
for (auto *view : views) {
view->setContextMenuPolicy(Qt::NoContextMenu);
}
}
void DiagramEventAddPaste::showHint()
{
if (m_status_bar) {
m_status_bar->showMessage(
tr("Cliquez pour poser le collage, Échap ou clic droit pour annuler",
"status bar tip while positioning a paste"));
}
}
/**
@brief DiagramEventAddPaste::moveTo
Put the group's top left corner at @a scene_pos, snapped to the grid.
*/
void DiagramEventAddPaste::moveTo(const QPointF &scene_pos)
{
const QPointF anchor = Diagram::snapToGrid(scene_pos);
for (auto it = m_relative_pos.constBegin() ; it != m_relative_pos.constEnd() ; ++it) {
if (it.key()) {
it.key()->setPos(anchor + it.value());
}
}
}
void DiagramEventAddPaste::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{
if (!m_running) return;
moveTo(event->scenePos());
event->setAccepted(true);
}
void DiagramEventAddPaste::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
if (!m_running) return;
//Swallowed so the press cannot start a rubber band or drag an item
//out of the group; the release is what decides.
event->setAccepted(true);
}
void DiagramEventAddPaste::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
{
if (!m_running) return;
if (event->button() == Qt::LeftButton) {
moveTo(event->scenePos());
commit();
} else if (event->button() == Qt::RightButton) {
cancel();
}
event->setAccepted(true);
}
void DiagramEventAddPaste::keyPressEvent(QKeyEvent *event)
{
if (!m_running) return;
switch (event->key()) {
case Qt::Key_Escape:
cancel();
event->setAccepted(true);
break;
//Return and Enter drop the paste where it stands, so the whole
//operation can be completed without a mouse.
case Qt::Key_Return:
case Qt::Key_Enter:
commit();
event->setAccepted(true);
break;
default:
break;
}
}
/**
@brief DiagramEventAddPaste::commit
Hand the items to the undo stack where they stand.
PasteDiagramCommand's first redo() does not add the items to the scene --
it assumes they are already there, which is what Diagram::fromXml did when
this started. So pushing it here adopts them rather than duplicating them.
*/
void DiagramEventAddPaste::commit()
{
if (m_finished || !m_diagram) return;
m_finished = true;
m_running = false;
m_diagram->undoStack().push(new PasteDiagramCommand(m_diagram, m_content));
emit finish();
}
/**
@brief DiagramEventAddPaste::cancel
Take the items back off the folio. Nothing was pushed to the undo stack,
so there is nothing to undo afterwards.
*/
void DiagramEventAddPaste::cancel()
{
if (m_finished || !m_diagram) return;
m_finished = true;
m_running = false;
//Conductors first: they hold pointers to the terminals of the
//elements below, so removing an element out from under one would
//leave it pointing at freed memory for as long as it is still in the
//scene.
const QList<Conductor *> conductors = m_content.conductors(DiagramContent::AnyConductor);
for (auto *conductor : conductors) {
m_diagram->removeItem(conductor);
delete conductor;
}
const QList<QGraphicsItem *> rest = m_content.items(MovableItems);
for (auto *item : rest) {
m_diagram->removeItem(item);
delete item;
}
m_content.clear();
m_relative_pos.clear();
emit finish();
}
@@ -0,0 +1,85 @@
/*
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 DIAGRAMEVENTADDPASTE_H
#define DIAGRAMEVENTADDPASTE_H
#include "diagrameventinterface.h"
#include "../diagramcontent.h"
#include <QHash>
#include <QPointer>
class QStatusBar;
/**
@brief The DiagramEventAddPaste class
Paste the clipboard as a placement you can still move.
The pasted items are added straight away and follow the cursor until a
left click drops them; Escape or a right click takes them away again.
This is the same interaction as placing a new element, so a paste behaves
like every other "put something on the folio" action.
The items are the real ones from the start, not a preview: Diagram::fromXml
creates them, this class moves them, and PasteDiagramCommand is pushed only
once they are dropped. That keeps one copy of the paste logic rather than
two, and means a cancelled paste leaves nothing on the undo stack.
*/
class DiagramEventAddPaste : public DiagramEventInterface
{
Q_OBJECT
public:
///Items with a position of their own. Conductors are left out
///deliberately: they are drawn from their terminals, so they
///follow when the elements they attach to move.
static const int MovableItems =
DiagramContent::Elements
| DiagramContent::TextFields
| DiagramContent::Images
| DiagramContent::Shapes
| DiagramContent::Tables
| DiagramContent::TerminalStrip;
DiagramEventAddPaste(Diagram *diagram, const QPointF &start_pos);
~DiagramEventAddPaste() override;
void mouseMoveEvent (QGraphicsSceneMouseEvent *event) override;
void mousePressEvent (QGraphicsSceneMouseEvent *event) override;
void mouseReleaseEvent (QGraphicsSceneMouseEvent *event) override;
void keyPressEvent (QKeyEvent *event) override;
void init() override;
///@return true if the clipboard holds something this can paste.
static bool clipboardHasDiagram();
private:
void moveTo(const QPointF &scene_pos);
void commit();
void cancel();
void showHint();
DiagramContent m_content;
///Each movable item's position relative to the group's top left,
///taken once so repeated moves cannot accumulate rounding drift.
QHash<QGraphicsItem *, QPointF> m_relative_pos;
QPointer<QStatusBar> m_status_bar;
bool m_finished{false};
};
#endif // DIAGRAMEVENTADDPASTE_H
+34
View File
@@ -702,6 +702,27 @@ void DiagramView::focusInEvent(QFocusEvent *e) {
}
}
/**
@brief DiagramView::focusNextPrevChild
By default, QWidget intercepts Tab/Shift+Tab to move keyboard focus to
the next/previous widget before a key press event is ever generated,
which would silently swallow the diagram's Tab-based item-selection
cycling (see Diagram::event()). Returning false here disables that
automatic focus-chain traversal for this view, so Tab/Shift+Tab reach
keyPressEvent() (and from there, the scene) as ordinary key presses
instead.
@return always false
*/
bool DiagramView::focusNextPrevChild(bool next)
{
//Escape asked for focus to leave; allow exactly this one traversal.
if (m_releasing_focus) {
m_releasing_focus = false;
return QGraphicsView::focusNextPrevChild(next);
}
return false;
}
/**
@brief DiagramView::keyPressEvent
Handles "key press" events. Reimplemented here to switch to visualisation
@@ -717,6 +738,19 @@ void DiagramView::keyPressEvent(QKeyEvent *e)
DiagramContent dc(m_diagram);
switch(e -> key())
{
case Qt::Key_Escape:
//Tab cycles the folio's items rather than moving focus (see
//focusNextPrevChild above), so without this there would be no
//way off the canvas for someone working without a mouse.
//Escape steps back out: first it drops the selection, then it
//hands focus to the next widget.
if (m_diagram && !m_diagram->selectedItems().isEmpty()) {
m_diagram->clearSelection();
} else {
m_releasing_focus = true;
focusNextChild();
}
return;
case Qt::Key_PageUp:
current_project->changeTabUp();
return;
+3
View File
@@ -80,6 +80,9 @@ class DiagramView : public QGraphicsView
void keyPressEvent(QKeyEvent *) override;
void keyReleaseEvent(QKeyEvent *) override;
bool event(QEvent *) override;
bool focusNextPrevChild(bool next) override;
///Set for one call only, by the Escape handler, to let focus leave the view.
bool m_releasing_focus = false;
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *) override;
void mouseMoveEvent(QMouseEvent *) override;
+42 -2
View File
@@ -29,6 +29,7 @@
#include "diagramevent/diagrameventaddshape.h"
#include "diagramevent/diagrameventaddpath.h"
#include "diagramevent/diagrameventaddtext.h"
#include "diagramevent/diagrameventaddpaste.h"
#include "diagramview.h"
#include "elementspanelwidget.h"
#include "factory/qetgraphicstablefactory.h"
@@ -349,8 +350,21 @@ void QETDiagramEditor::setUpActions()
currentDiagramView()->copy();
});
connect(m_paste, &QAction::triggered, [this]() {
if(currentDiagramView())
currentDiagramView()->paste();
auto *dv = currentDiagramView();
if (!dv || !dv->diagram()) return;
//Paste as a placement rather than dropping the items straight
//down. Pasting in place put the copy exactly on top of the
//original, where it was easy to miss entirely; now it appears
//under the cursor and follows it until a click, Return, or Escape
//to cancel -- the same interaction as placing a new element.
const QPoint view_pos = dv->viewport()->mapFromGlobal(QCursor::pos());
const QPointF start_pos = dv->viewport()->rect().contains(view_pos)
? dv->mapToScene(view_pos)
: dv->mapToScene(dv->viewport()->rect().center());
dv->diagram()->setEventInterface(
new DiagramEventAddPaste(dv->diagram(), start_pos));
});
//Reset conductor path
@@ -688,18 +702,28 @@ void QETDiagramEditor::setUpActions()
QAction *select_all = m_select_actions_group.addAction( QET::Icons::EditSelectAll, tr("Tout sélectionner") );
QAction *select_nothing = m_select_actions_group.addAction( QET::Icons::EditSelectNone, tr("Désélectionner tout") );
QAction *select_invert = m_select_actions_group.addAction( QET::Icons::EditSelectInvert, tr("Inverser la sélection") );
QAction *select_all_conductors = m_select_actions_group.addAction( QET::Icons::Conductor, tr("Sélectionner tous les conducteurs") );
QAction *select_all_text_fields = m_select_actions_group.addAction( QET::Icons::PartTextField, tr("Sélectionner tous les champs de texte") );
ShortcutManager::instance().registerAction(select_all, "diagrameditor.select_all", tr("Éditeur de schémas"), QKeySequence::SelectAll);
ShortcutManager::instance().registerAction(select_nothing, "diagrameditor.select_nothing", tr("Éditeur de schémas"), QKeySequence::Deselect);
ShortcutManager::instance().registerAction(select_invert, "diagrameditor.select_invert", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_I);
//No default sequence for these two: they are menu actions, and the
//point of registering them is so a user can bind one if they want.
ShortcutManager::instance().registerAction(select_all_conductors, "diagrameditor.select_all_conductors", tr("Éditeur de schémas"), QKeySequence());
ShortcutManager::instance().registerAction(select_all_text_fields, "diagrameditor.select_all_text_fields", tr("Éditeur de schémas"), QKeySequence());
select_all ->setStatusTip( tr("Sélectionne tous les éléments du folio", "status bar tip") );
select_nothing->setStatusTip( tr("Désélectionne tous les éléments du folio", "status bar tip") );
select_invert ->setStatusTip( tr("Désélectionne les éléments sélectionnés et sélectionne les éléments non sélectionnés", "status bar tip") );
select_all_conductors ->setStatusTip( tr("Sélectionne tous les conducteurs du folio, désélectionne le reste", "status bar tip") );
select_all_text_fields->setStatusTip( tr("Sélectionne tous les champs de texte du folio, désélectionne le reste", "status bar tip") );
select_all ->setData("select_all");
select_nothing->setData("deselect");
select_invert ->setData("invert_selection");
select_all_conductors ->setData("select_all_conductors");
select_all_text_fields->setData("select_all_text_fields");
connect(&m_select_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::selectGroupTriggered);
@@ -934,6 +958,15 @@ void QETDiagramEditor::setUpMenu()
menu_edition -> addAction(m_cut);
menu_edition -> addAction(m_copy);
menu_edition -> addAction(m_paste);
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
//toolbar button has no key, so text fields, images and every drawing
//shape simply could not be added. m_depth_action_group below has
//always been in both places; this brings these into line with it.
QMenu *menu_add_item = menu_edition -> addMenu(tr("A&jouter"));
menu_add_item -> setIcon(QET::Icons::Add);
menu_add_item -> addActions(m_add_item_actions_group.actions());
menu_edition -> addSeparator();
menu_edition -> addActions(m_select_actions_group.actions());
menu_edition -> addSeparator();
@@ -952,6 +985,9 @@ void QETDiagramEditor::setUpMenu()
// menu Projet
menu_project -> addAction(m_project_edit_properties);
menu_project -> addAction(m_auto_conductor);
//Sits beside m_auto_conductor, the setting it pairs with. It was
//toolbar-only and so had no keyboard route at all.
menu_project -> addAction(m_auto_break_conductor);
menu_project -> addSeparator();
menu_project -> addAction(m_project_add_diagram);
menu_project -> addAction(m_remove_diagram_from_project);
@@ -1584,6 +1620,10 @@ void QETDiagramEditor::selectGroupTriggered(QAction *action)
diagram->deselectAll();
else if (value == "invert_selection")
diagram->invertSelection();
else if (value == "select_all_conductors")
diagram->selectAllConductors();
else if (value == "select_all_text_fields")
diagram->selectAllTextFields();
}
/**
+38
View File
@@ -19,6 +19,7 @@
#include <QWhatsThis>
#include <QMenu>
#include <QMenuBar>
#include <QShortcut>
#include <QDragEnterEvent>
#include <QDesktopServices>
@@ -41,6 +42,14 @@ QETMainWindow::QETMainWindow(QWidget *widget, Qt::WindowFlags flags) :
initCommonMenus();
setAcceptDrops(true);
//A shortcut rather than a key handler: a key press goes to the
//focused child widget, so a keyPressEvent() here would never see F10
//while the canvas or a panel holds focus.
QShortcut *menu_bar_shortcut = new QShortcut(QKeySequence(Qt::Key_F10), this);
menu_bar_shortcut -> setContext(Qt::WindowShortcut);
connect(menu_bar_shortcut, &QShortcut::activated,
this, &QETMainWindow::activateMenuBar);
}
/**
@@ -251,6 +260,35 @@ void QETMainWindow::checkToolbarsmenu()
/**
Handle the \a e event.
*/
/**
@brief QETMainWindow::activateMenuBar
Open the first usable menu, as pressing Alt and a menu's letter would.
F10 is what most applications use for this, and QMenuBar does not handle
it: given the key directly it leaves it unaccepted, and sent to the window
it never reaches the menu bar at all, because a key press goes to the
focused child widget. So the press fell through to whichever widget had
focus and looked like nothing happening.
This is convenience, not access. Qt already provides two keyboard routes
into the menus and both work: a bare Alt tap focuses the bar, and Alt with
a menu's letter opens it. This adds the key people reach for out of habit.
A shortcut rather than a keyPressEvent() override, for the reason above --
the window never sees the key while a child holds focus.
*/
void QETMainWindow::activateMenuBar() {
QMenuBar *bar = menuBar();
if (!bar) return;
for (QAction *action : bar -> actions()) {
if (action -> isVisible() && action -> isEnabled() && action -> menu()) {
bar -> setActiveAction(action);
return;
}
}
}
bool QETMainWindow::event(QEvent *e) {
if (e -> type() == QEvent::WindowStateChange) {
updateFullScreenAction();
+1
View File
@@ -39,6 +39,7 @@ class QETMainWindow : public QMainWindow {
QAction *actionForMenu(QMenu *);
protected:
void activateMenuBar();
bool event(QEvent *) override;
void dragEnterEvent(QDragEnterEvent *e) override;
void dropEvent(QDropEvent *e) override;
+73
View File
@@ -0,0 +1,73 @@
# IPC open-forwarding regression test
Guards the use-after-free fixed in PR #868.
With QElectroTech already running, opening a `.qet` from a file manager forwards
the path to the running instance over SingleApplication's local socket. Before
#868, `QETApp::receiveMessage()` called `openFiles()` directly — inside the
socket's `readyRead` handler. Loading the project raises a modal backup prompt
whose `exec()` runs a nested event loop while that handler is still on the
stack. The second instance then exits, its socket is deleted, and when the
prompt is dismissed and the stack unwinds, `QMetaObject::activate()` carries on
emitting on freed memory.
## Running it
```bash
tests/ipc-regression/run.sh --binary build/qelectrotech
```
Needs `Xvfb`, `openbox` and `xdotool`. It allocates its own display, runs
entirely in a sandbox, and cleans up after itself.
Exit codes: `0` survived, `1` crashed, `2` inconclusive or unusable input.
## Requirements that are not obvious
**Qt 6.** An unfixed Qt 5 build survives this scenario every time — measured,
not assumed. The script refuses to run on a Qt 5 binary rather than report a
pass that cannot fail. Reproduced on Qt 6.10.2.
**A Debug build.** Whether a use-after-free faults depends on what the allocator
does with the freed block. The same unfixed commit crashes 3 times in 3 built
Debug, and survives every attempt built `-O3 -DNDEBUG`.
**A large project.** The load has to take long enough that the prompt is up
while the socket handler is still on the stack. `examples/industrial.qet`
(2.6 MB, ~4 s) is used by default.
## Why it repeats
The crash is a race: the second instance's disconnect has to be processed while
the nested event loop is on the stack. Single attempts reproduced it 2 times in
3, so one attempt would let a reintroduced bug through about a third of the
time. Three attempts is roughly 96%; `QET_IPC_ATTEMPTS` changes it. Any single
crash fails the run, and survival must be unanimous.
## Validation
| build | result |
|---|---|
| `ceda1e082` (before #868) | 3/3 crashed, exit 139 |
| `199444b6` (after #868) | 3/3 survived |
If this test is changed, re-validate it against a build without the fix. A gate
nobody has watched go red is not evidence.
## Things that made earlier versions of this test pass a crashing build
Recorded because each one looked exactly like success.
**Answering the wrong dialog.** A progress dialog appears several seconds before
the backup prompt and both stay up. Dismissing the progress dialog satisfies a
naive "a modal appeared, dismiss it" check while never touching the crash path.
**Filtering that dialog out by title.** That is a locale filter: it worked in
French and silently stopped working in English. The script now dismisses every
dialog and identifies none, and treats a run that saw fewer than two distinct
dialogs as inconclusive rather than passing.
**Sharing a home directory between attempts.** A crashed attempt leaves a backup
file, so the next one opens with the restore prompt instead of the backup
prompt — a path that never unwinds the socket stack. Each attempt now gets a
fresh `HOME`.
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env bash
#
# IPC open-forwarding regression gate -- upstream PR #868.
#
# tests/ipc-regression/run.sh --binary build-fast/qelectrotech
#
# WHAT IT GUARDS
#
# With QET already running, opening a .qet from a file manager forwards the
# path to the running instance over SingleApplication's local socket. Before
# #868, QETApp::receiveMessage() called openFiles() directly -- inside the
# socket's readyRead handler. openFiles() loads the project (seconds, on a
# large one) and puts up a modal BackupDialog whose exec() runs a NESTED event
# loop while the socket handler is still on the stack. During that nested loop
# the secondary exits, the connection closes, the QLocalSocket is deleted, and
# when the dialog is dismissed and the stack unwinds QMetaObject::activate()
# carries on emitting on the freed sender. Segfault.
#
# #868 defers openFiles() with a zero-timer so the socket stack unwinds first.
# The comment there is long on purpose; this test is the other half of making
# sure nobody "simplifies" it away.
#
# THE STEP EVERYONE MISSES
#
# Leaving the backup dialog OPEN never crashes -- the stack never unwinds.
# The bug was twice reported unreproducible for exactly this reason. DISMISSING
# the dialog is the load-bearing step, not opening the project. That is why
# this test needs a window manager and synthetic input rather than being a
# headless CLI check.
#
# WHY A COPIED BINARY (the isolation that makes this safe to run)
#
# SingleApplication hashes the socket name from app/org name, version, and --
# on Linux, unless ExcludeAppPath is set -- applicationFilePath()
# (SingleApplication/singleapplication_p.cpp:156-171). QET constructs it as
# `SingleApplication app(argc, argv, true)` (sources/main.cpp:160), so the
# path IS in the hash.
#
# That matters twice over:
# * Qt's local sockets live in the abstract namespace, which is scoped to the
# network namespace, so a container sharing the host's network namespace
# and running the same binary path will silently capture native launches.
# * Copying the binary to a unique path therefore gives this test its OWN
# socket. It cannot talk to, or be hijacked by, a QElectroTech you already
# have open -- and two runs of this test cannot collide with each other.
#
# Do not "optimise" the copy into a symlink: applicationFilePath() resolves
# through /proc/self/exe, so a symlink lands back on the real path and the
# isolation is silently lost.
#
set -uo pipefail
BINARY=""
PROJECT=""
KEEP_OPEN=0
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY="$2"; shift 2 ;;
--project) PROJECT="$2"; shift 2 ;;
--keep-open) KEEP_OPEN=1; shift ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$BINARY" ] || { echo "usage: $0 --binary <qet> [--project <file.qet>]" >&2; exit 2; }
[ -x "$BINARY" ] || { echo "not executable: $BINARY" >&2; exit 2; }
BINARY="$(readlink -f "$BINARY")"
# A big project is required, not incidental: the load has to take long enough
# that the modal is up while the socket handler is still on the stack.
# industrial.qet is 2.6 MB and loads in ~4 s.
if [ -z "$PROJECT" ]; then
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
for cand in "$(dirname "$BINARY")/../examples/industrial.qet" \
"$SCRIPT_DIR/../../examples/industrial.qet"; do
[ -f "$cand" ] && { PROJECT="$(readlink -f "$cand")"; break; }
done
fi
[ -f "$PROJECT" ] || { echo "no project found; pass --project" >&2; exit 2; }
for tool in Xvfb openbox xdotool; do
command -v "$tool" >/dev/null || { echo "missing required tool: $tool" >&2; exit 2; }
done
# Qt 5 cannot prove anything here. An unfixed Qt 5 build of master survives
# this scenario every time -- measured, not assumed -- so a Qt 5 run reports
# PASS whether or not the bug is present. Refusing is the only honest answer:
# a green that cannot go red is worse than no test.
#
# Reproduced on Qt 6.10.2, which is also the version in the original report.
# Whether older Qt 6 reproduces it is UNVERIFIED -- if you run this on Qt 6.2
# or 6.4 and it passes, confirm against a deliberately unfixed build before
# believing it.
if ldd "$BINARY" 2>/dev/null | grep -q "libQt5Core"; then
echo "INCONCLUSIVE: $BINARY links Qt 5." >&2
echo " This crash only reproduces on Qt 6; an unfixed Qt 5 build" >&2
echo " survives every attempt. Build against Qt 6 to use this gate." >&2
exit 2
fi
SANDBOX="$(mktemp -d /tmp/qet-ipc-regression.XXXXXX)"
LOG_DIR="$SANDBOX/logs"; mkdir -p "$LOG_DIR"
PRIMARY_PID=""; SECONDARY_PID=""; XVFB_PID=""; OPENBOX_PID=""
cleanup() {
if [ "$KEEP_OPEN" = "1" ]; then
echo "--keep-open: DISPLAY=$DISPLAY sandbox=$SANDBOX (nothing killed)"
return
fi
for pid in "$SECONDARY_PID" "$PRIMARY_PID" "$OPENBOX_PID" "$XVFB_PID"; do
[ -n "$pid" ] && kill "$pid" 2>/dev/null
done
sleep 0.3
for pid in "$SECONDARY_PID" "$PRIMARY_PID" "$OPENBOX_PID" "$XVFB_PID"; do
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null
done
[ "${KEEP_LOGS:-0}" = "1" ] || rm -rf "$SANDBOX"
}
trap cleanup EXIT
# Unique binary path == private SingleApplication socket. See header.
TEST_BINARY="$SANDBOX/qelectrotech-ipctest"
cp "$BINARY" "$TEST_BINARY" || { echo "could not copy binary" >&2; exit 2; }
# Work on a COPY of the project, inside the sandbox.
#
# Answering "yes" to the backup prompt is the whole point of this test, and
# QElectroTech writes that backup next to the project it opened. Pointed at
# examples/ directly, a three-attempt run drops three dated .qet files into the
# source tree and leaves them there. Copying first keeps the repository clean
# and means the teardown takes the backups with it.
SANDBOX_PROJECT="$SANDBOX/$(basename "$PROJECT")"
cp "$PROJECT" "$SANDBOX_PROJECT" || { echo "could not copy project" >&2; exit 2; }
PROJECT="$SANDBOX_PROJECT"
# Isolated HOME so the run cannot inherit or leave behind real settings.
export HOME="$SANDBOX/home"
export XDG_CONFIG_HOME="$HOME/.config"
export XDG_DATA_HOME="$HOME/.local/share"
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME"
# Xvfb -displayfd atomically picks a free display; hardcoding :99 races.
Xvfb -displayfd 9 -screen 0 1600x1000x24 >"$LOG_DIR/xvfb.log" 2>&1 9>"$SANDBOX/dispnum" &
XVFB_PID=$!
for _ in $(seq 1 50); do
DISP_NUM="$(cat "$SANDBOX/dispnum" 2>/dev/null | tr -d '[:space:]')"
[ -n "$DISP_NUM" ] && break
sleep 0.1
done
[ -n "${DISP_NUM:-}" ] || { echo "FAIL: Xvfb never reported a display" >&2; exit 1; }
export DISPLAY=":$DISP_NUM"
# openbox is mandatory -- without a WM, xdotool cannot activate windows
# ("your windowmanager claims not to support _NET_ACTIVE_WINDOW").
openbox >"$LOG_DIR/openbox.log" 2>&1 &
OPENBOX_PID=$!
sleep 1
# Only count real windows: Qt/QPA leaves a HIDDEN 1x1 helper window whose
# title matches the main window exactly, so name-only matching is a coin flip.
real_window() {
local geo w h
geo="$(xdotool getwindowgeometry --shell "$1" 2>/dev/null)" || return 1
w="$(echo "$geo" | sed -n 's/^WIDTH=//p')"
h="$(echo "$geo" | sed -n 's/^HEIGHT=//p')"
[ -n "$w" ] && [ -n "$h" ] && [ "$w" -gt 50 ] && [ "$h" -gt 50 ]
}
main_window() {
local w
for w in $(xdotool search --onlyvisible --name "QElectroTech" 2>/dev/null); do
real_window "$w" && { echo "$w"; return 0; }
done
return 1
}
# Any real window that is not the main window. NOT filtered by title -- see
# below for why that matters more than it looks.
modal_window() {
local main="$1" w
for w in $(xdotool search --onlyvisible --name "." 2>/dev/null); do
[ "$w" = "$main" ] && continue
real_window "$w" && { echo "$w"; return 0; }
done
return 1
}
# Every non-main real window, newest last (X window ids increase).
modal_windows() {
local main="$1" w out=""
for w in $(xdotool search --onlyvisible --name "." 2>/dev/null | sort -n); do
[ "$w" = "$main" ] && continue
real_window "$w" && out="$out $w"
done
echo "$out"
}
# WHY DIALOGS ARE NOT IDENTIFIED BY TITLE
#
# Opening a project raises a progress dialog several seconds BEFORE the backup
# prompt, and both stay up. Answering the progress dialog looks exactly like a
# successful run: a modal was found, a key was sent, the app survived. PASS,
# against a build that crashes reliably.
#
# The first version of this test filtered the progress dialog out by title.
# That worked locally ("Merci de patienter") and silently broke in Docker,
# where the same dialog is "Thank you for your patience" -- a green that could
# not go red, which is the exact failure this test exists to prevent. A title
# list is a locale list, and there is no reason to believe anyone will keep it
# in sync with QET's translations.
#
# So: dismiss every dialog that appears, repeatedly, and identify nothing.
# The progress dialog ignores Return harmlessly; the backup prompt takes it.
# The run is only meaningful if at least TWO distinct dialogs were seen (the
# progress dialog plus the prompt) -- one means the prompt never appeared and
# the crash path was never exercised, which is INCONCLUSIVE, not a pass.
ATTEMPTS="${QET_IPC_ATTEMPTS:-3}"
echo "── QElectroTech IPC open-forwarding regression (PR #868) ──"
echo "binary : $BINARY"
echo "project : $(basename "$PROJECT")"
echo "display : $DISPLAY"
echo "attempts : $ATTEMPTS"
echo
# WHY THIS REPEATS
#
# The crash is a race: it needs the secondary's disconnect to be processed
# while the modal's nested event loop is still on the stack. Measured on an
# unfixed Qt 6 build of master, a single attempt reproduced it 2 times in 3.
# One attempt is therefore not a gate -- it would wave a reintroduced
# use-after-free through about a third of the time.
#
# Attempts are independent, so N runs miss it with probability (1/3)^N:
# 3 attempts ~= 96% detection, 5 ~= 99.6%. Any single crash fails the run;
# survival must be unanimous.
#
# A fixed build passes every attempt, so the repetition costs nothing but
# time on a green build.
# Runs the scenario once. 0 = survived, 1 = crashed, 2 = inconclusive.
run_once() {
local attempt="$1"
local primary_pid="" secondary_pid="" main="" modal=""
local log="$LOG_DIR/attempt-$attempt"
mkdir -p "$log"
# Each attempt gets a FRESH HOME. A crashed attempt leaves a backup file
# behind, and the next run then opens with "Restore file" instead of --
# or before -- "Create a backup copy?". Observed directly: attempt 2 of a
# Docker run saw a restore prompt and never saw the backup prompt at all,
# so it exercised a different path and proved nothing. Attempts have to be
# independent or the repetition is not buying what it claims to.
export HOME="$SANDBOX/home-$attempt"
export XDG_CONFIG_HOME="$HOME/.config"
export XDG_DATA_HOME="$HOME/.local/share"
rm -rf "$HOME"
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME"
"$TEST_BINARY" >"$log/primary.log" 2>&1 &
primary_pid=$!
for _ in $(seq 1 100); do
main="$(main_window)" && [ -n "$main" ] && break
kill -0 "$primary_pid" 2>/dev/null || { echo " primary died during startup"; return 2; }
sleep 0.2
done
[ -n "$main" ] || { kill "$primary_pid" 2>/dev/null; echo " primary never showed a window"; return 2; }
for _ in $(seq 1 10); do
m="$(modal_window "$main")" || break
xdotool windowactivate --sync "$m" 2>/dev/null
xdotool key --clearmodifiers Escape 2>/dev/null
sleep 0.3
done
"$TEST_BINARY" "$PROJECT" >"$log/secondary.log" 2>&1 &
secondary_pid=$!
local seen_ids="" distinct=0 crashed=0
for _ in $(seq 1 200); do
if ! kill -0 "$primary_pid" 2>/dev/null; then crashed=1; break; fi
for w in $(modal_windows "$main"); do
case " $seen_ids " in
*" $w "*) ;;
*) seen_ids="$seen_ids $w"
distinct=$((distinct + 1))
echo " dialog $distinct: '$(xdotool getwindowname "$w" 2>/dev/null)'" ;;
esac
xdotool windowactivate --sync "$w" 2>/dev/null
xdotool key --clearmodifiers Return 2>/dev/null
done
[ "$distinct" -ge 2 ] && [ -z "$(modal_windows "$main")" ] && break
sleep 0.2
done
if [ "$crashed" = "0" ] && [ "$distinct" -lt 2 ]; then
kill "$primary_pid" "$secondary_pid" 2>/dev/null
echo " only $distinct dialog(s) seen -- the backup prompt never appeared,"
echo " so the crash path was not exercised"
return 2
fi
sleep 2
local result=0
if ! kill -0 "$primary_pid" 2>/dev/null; then
wait "$primary_pid" 2>/dev/null; local rc=$?
echo " CRASH: primary died after dismissal (exit $rc)"
result=1
elif grep -qiE "segmentation fault|SIGSEGV|AddressSanitizer" "$log/primary.log" 2>/dev/null; then
echo " CRASH: primary logged a crash signature"
result=1
else
echo " survived"
fi
kill "$primary_pid" "$secondary_pid" 2>/dev/null
sleep 0.5
kill -9 "$primary_pid" "$secondary_pid" 2>/dev/null
return $result
}
crashes=0
inconclusive=0
for attempt in $(seq 1 "$ATTEMPTS"); do
echo "attempt $attempt/$ATTEMPTS"
run_once "$attempt"
case $? in
1) crashes=$((crashes + 1)) ;;
2) inconclusive=$((inconclusive + 1)) ;;
esac
done
echo
if [ "$crashes" -gt 0 ]; then
echo "FAIL: primary crashed in $crashes of $ATTEMPTS attempts."
echo " This is the PR #868 use-after-free -- forwarded files are being"
echo " opened inside the SingleApplication socket handler again."
echo " Logs: $LOG_DIR (preserved)"
KEEP_LOGS=1
exit 1
fi
if [ "$inconclusive" -eq "$ATTEMPTS" ]; then
echo "INCONCLUSIVE: every attempt failed to exercise the crash path."
echo " Nothing was proven. Check the binary forwards at all (needs"
echo " upstream #861) and that the project is large enough to raise"
echo " the backup prompt."
exit 2
fi
echo "PASS: primary survived $((ATTEMPTS - inconclusive)) of $ATTEMPTS attempts, none crashed."
[ "$inconclusive" -gt 0 ] && echo " ($inconclusive attempt(s) inconclusive.)"
exit 0
+7
View File
@@ -113,3 +113,10 @@ add_executable(
add_test(NAME tst_qetstrings COMMAND tst_qetstrings)
target_include_directories(tst_qetstrings PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_qetstrings PRIVATE Qt::Test Qt::Widgets Qt::Xml)
add_executable(
tst_menubarkeyboard
tst_menubarkeyboard.cpp)
add_test(NAME tst_menubarkeyboard COMMAND tst_menubarkeyboard)
target_include_directories(tst_menubarkeyboard PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_menubarkeyboard PRIVATE Qt::Test Qt::Widgets)
+138
View File
@@ -0,0 +1,138 @@
/*
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 <QtTest>
#include <QMainWindow>
#include <QMenu>
#include <QMenuBar>
#include <QShortcut>
/*
F10 should open the menu bar, as it does in most applications and as
someone working without a mouse will expect. Qt provides this on Windows
but not on X11, so QElectroTech adds it (QETMainWindow::activateMenuBar).
These tests use QTest rather than driving a real X server. That is not a
convenience: xdotool on Xvfb delivers every function key with Alt held, so
the application receives Alt+F10 and never the plain key. Two rounds of
GUI automation gave confident, wrong answers about F10 before that was
understood. QTest posts the event directly to the widget, so the key
arrives exactly as written.
*/
class TstMenuBarKeyboard : public QObject
{
Q_OBJECT
private slots:
void altLetterOpensMenu(); // control -- must pass, or nothing below means anything
void plainF10DoesNothingInQt(); // the gap being filled
void shortcutOpensMenuBar(); // the mechanism QETMainWindow uses
};
namespace {
QMainWindow *makeWindow(QMenu **file_menu)
{
auto *w = new QMainWindow;
*file_menu = w->menuBar()->addMenu(QStringLiteral("&File"));
(*file_menu)->addAction(QStringLiteral("Quit"));
w->menuBar()->addMenu(QStringLiteral("&Edit"))->addAction(QStringLiteral("Copy"));
w->resize(600, 400);
w->show();
w->activateWindow();
w->raise();
return w;
}
}
/*
The control. Alt and a menu's letter is known to work, so if this fails
the environment cannot open menus at all and the other two tests are
measuring nothing.
*/
void TstMenuBarKeyboard::altLetterOpensMenu()
{
QMenu *file = nullptr;
QScopedPointer<QMainWindow> w(makeWindow(&file));
QVERIFY(QTest::qWaitForWindowExposed(w.data()));
QTest::keyClick(w.data(), Qt::Key_F, Qt::AltModifier);
QTest::qWait(300);
QVERIFY2(file->isVisible(),
"control failed: Alt+F did not open a menu, so this environment "
"cannot judge any of the keyboard tests below");
}
/*
Records why the shortcut in QETMainWindow exists. If a future Qt starts
handling F10 on this platform, this test fails and the shortcut can go.
*/
void TstMenuBarKeyboard::plainF10DoesNothingInQt()
{
QMenu *file = nullptr;
QScopedPointer<QMainWindow> w(makeWindow(&file));
QVERIFY(QTest::qWaitForWindowExposed(w.data()));
QTest::keyClick(w.data(), Qt::Key_F10, Qt::NoModifier);
QTest::qWait(300);
QVERIFY2(!file->isVisible() && w->menuBar()->activeAction() == nullptr,
"Qt now handles F10 by itself -- QETMainWindow's shortcut is "
"redundant and can be removed");
}
/*
The mechanism QETMainWindow uses, exercised on a plain QMainWindow.
Worth being clear about what this does not cover: it repeats the shortcut
wiring rather than driving QETMainWindow itself, because
initCommonActions() calls QETApp::instance() and constructing that pulls
in the whole application -- element collections and all -- which does not
belong in a unit test. So this proves the approach works and would catch
it breaking in a future Qt; it does not prove QETMainWindow is wired up.
That last step needs someone to press F10 in a running QElectroTech.
*/
void TstMenuBarKeyboard::shortcutOpensMenuBar()
{
QMenu *file = nullptr;
QScopedPointer<QMainWindow> holder(makeWindow(&file));
QMainWindow *w = holder.data();
QVERIFY(QTest::qWaitForWindowExposed(w));
auto *shortcut = new QShortcut(QKeySequence(Qt::Key_F10), w);
shortcut->setContext(Qt::WindowShortcut);
QObject::connect(shortcut, &QShortcut::activated, w, [w]() {
for (QAction *action : w->menuBar()->actions()) {
if (action->isVisible() && action->isEnabled() && action->menu()) {
w->menuBar()->setActiveAction(action);
return;
}
}
});
QTest::keyClick(w, Qt::Key_F10, Qt::NoModifier);
QTest::qWait(300);
QVERIFY2(w->menuBar()->activeAction() != nullptr,
"F10 did not activate the menu bar");
QCOMPARE(w->menuBar()->activeAction()->text(), QStringLiteral("&File"));
}
QTEST_MAIN(TstMenuBarKeyboard)
#include "tst_menubarkeyboard.moc"