Merge remote-tracking branch 'origin/master' into fix-crossref-textfield

This commit is contained in:
Kellermorph
2026-09-16 11:49:55 +02:00
24 changed files with 2892 additions and 1778 deletions
+17 -13
View File
@@ -1,3 +1,20 @@
[ca]
La col·lecció d'elements que s'inclou amb QElectrotech es proporciona tal com és
i sense cap garantia que sigui adequada per al vostre ús o que funcioni correctament.
L'ús, la modificació i la integració d'aquests elements en esquemes elèctrics
estan permesos sense restriccions, independentment de la llicència final que regeixi
els esquemes.
No es permet utilitzar aquest programari ni cap fitxer associat
com a dades de mostra per crear models d'aprenentatge automàtic.
Si redistribuïu la totalitat o una part de la col·lecció QElectroTech,
amb o sense modificacions, fora d'un esquema elèctric, heu de complir
les condicions de la llicència CC-BY:
Aquesta obra està subjecta a la llicència Reconeixement 3.0,
disponible en línia a http://creativecommons.org/licenses/by/3.0/ o bé
sol·licitant-la per correu a Creative Commons, 171 Second Street, Suite 300, San Francisco,
Califòrnia 94105, EUA.
[en]
The elements collection provided along with QElectroTech is provided as is and
without any warranty of fitness for your purpose or working.
@@ -96,19 +113,6 @@ Para ver una copia de esta licencia, visite
http://creativecommons.org/licenses/by/3.0/ o envie una carta a Creative
Commons, 171 Second Street, Suite 300, San Francisco, California 94105, USA.
[ca]
La col·lecció de símbols QElectrotech és distribuïda tal qual i sense cap
garantia d'idoneïtat d'ús ni de funcionament.
Es permet incondicionalment, amb independència de la llicència final, emprar,
editar, i incloure aquests símbols en esquemes elèctrics.
Si vostè redistribueix una part de la col·lecció de QElectrotech o tota ella,
amb condicions o sense, separadament d'un esquema elèctric, haurà de respectar
les condicions de la llicència CC-BY:
Aquesta obra es troba sota una llicència Reconeixement 3.0 de Creative Commons.
Per veure una còpia d'aquesta llicència visiti
http://creativecommons.org/licenses/by/3.0/ o enviï una carta a Creative
Commons, 171 Second Street, Suite 300, San Francisco, California 94105,
[cs]
Sbírka prvků poskytovaná společně s QElectroTechem je poskytována tak, jak je,
bez záruky nebo vhodnosti pro váš účal nebo práci.
+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
+1463 -1707
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+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
+6 -1
View File
@@ -150,8 +150,11 @@ 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);
void restoreText(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
+89 -15
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;
@@ -1218,30 +1252,70 @@ QList<QAction *> DiagramView::contextMenuActions() const
*/
void DiagramView::contextMenuEvent(QContextMenuEvent *e)
{
QGraphicsView::contextMenuEvent(e);
if(e->isAccepted())
return;
QPoint menu_pos = e->pos();
QPoint menu_global_pos = e->globalPos();
//A context menu raised from the keyboard (the Menu key, or
//Shift+F10) carries no useful position: Qt does not aim it at the
//selection. Two things then went wrong. QGraphicsView handed the
//event to whichever item held focus, which answered with its own
//generic Undo/Cut/Copy menu and accepted it, so the folio's real
//menu was never built; and had it got past that, itemAt() below
//would have looked up an unrelated point.
//
//So a keyboard-raised menu is built here directly rather than being
//offered to the items first, and aimed at the selection when there
//is one. The keyboard then gets the folio's menu, which is what a
//right-click gets.
const bool from_keyboard = e->reason() == QContextMenuEvent::Keyboard;
if (auto qgi = m_diagram->itemAt(mapToScene(e->pos()), transform()))
if (from_keyboard)
{
if (!qgi->isSelected()) {
m_diagram->clearSelection();
//Aim at the selection when there is one, so the menu appears
//beside what it acts on. With nothing selected there is nothing
//to aim at, so use the middle of the view -- the folio's own
//menu is still the right menu to show.
const auto selection = m_diagram->selectedItems();
if (!selection.isEmpty())
{
QRectF selection_rect;
for (auto *item : selection) {
selection_rect |= item->sceneBoundingRect();
}
menu_pos = mapFromScene(selection_rect.center());
}
else
{
menu_pos = viewport()->rect().center();
}
menu_global_pos = viewport()->mapToGlobal(menu_pos);
}
else
{
QGraphicsView::contextMenuEvent(e);
if(e->isAccepted())
return;
// At this step qgi can be deleted for example if qgi is a QetGraphicsHandlerItem.
// When we call clearSelection the parent item of the handler
// is deselected and so delete all handlers, in this case,
// qgi become a dangling pointer.
// we need to call again itemAt.
if (auto item_ = m_diagram->itemAt(mapToScene(e->pos()), transform())) {
item_->setSelected(true);
if (auto qgi = m_diagram->itemAt(mapToScene(menu_pos), transform()))
{
if (!qgi->isSelected()) {
m_diagram->clearSelection();
}
// At this step qgi can be deleted for example if qgi is a QetGraphicsHandlerItem.
// When we call clearSelection the parent item of the handler
// is deselected and so delete all handlers, in this case,
// qgi become a dangling pointer.
// we need to call again itemAt.
if (auto item_ = m_diagram->itemAt(mapToScene(menu_pos), transform())) {
item_->setSelected(true);
}
}
}
if (m_diagram->selectedItems().isEmpty())
{
m_paste_here_pos = e->pos();
m_paste_here_pos = menu_pos;
m_paste_here->setEnabled(Diagram::clipboardMayContainDiagram());
}
@@ -1250,7 +1324,7 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e)
{
QMenu *context_menu = new QMenu(this);
context_menu->addActions(list);
context_menu->popup(e->globalPos());
context_menu->popup(menu_global_pos);
e->accept();
}
}
+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;
+33 -5
View File
@@ -546,12 +546,40 @@ QString QET::joinWithSpaces(const QStringList &string_list) {
QStringList QET::splitWithSpaces(const QString &string) {
// les chaines sont separees par des espaces non echappes
// = avec un nombre nul ou pair de backslashes devant
QStringList escaped_strings = string.split(QRegularExpression("[^\\]?(?:\\\\)* "),Qt::SkipEmptyParts);
//
// This was a QRegularExpression("[^\\]?(?:\\\\)* ") split, which never
// worked: "[^\]" opens a character class whose "\]" is an escaped
// bracket, so the class is never closed and the pattern is invalid.
// QRegularExpression::isValid() was false, QString::split() warned
// "invalid QRegularExpression object" and returned an EMPTY list for
// every input -- so a second instance's file arguments were always
// dropped (bugtracker #248), not just ones containing spaces.
//
// A correct pattern is not expressible here either: the separator is a
// space preceded by an even-length run of backslashes, and PCRE2 has no
// variable-length lookbehind. Scanning explicitly is both correct and
// easier to read than the alternatives.
QStringList returned_list;
foreach(QString escaped_string, escaped_strings) {
returned_list << QET::unescapeSpaces(escaped_string);
QString current;
int backslashes = 0;
for (const QChar &c : string) {
if (c == QLatin1Char('\\')) {
++backslashes;
current += c;
continue;
}
if (c == QLatin1Char(' ') && backslashes % 2 == 0) {
if (!current.isEmpty()) {
returned_list << QET::unescapeSpaces(current);
}
current.clear();
} else {
current += c;
}
backslashes = 0;
}
if (!current.isEmpty()) {
returned_list << QET::unescapeSpaces(current);
}
return(returned_list);
}
+25 -1
View File
@@ -1659,7 +1659,31 @@ void QETApp::receiveMessage(int instanceId, QByteArray message)
{
QString my_message(str.mid(20));
QStringList args_list = QET::splitWithSpaces(my_message);
openFiles(QETArguments(args_list));
// Deferred, not called directly.
//
// This slot runs inside SingleApplication's readyRead handling:
// SingleApplicationPrivate::slotDataAvailable() emits
// receivedMessage() synchronously from the socket's readyRead
// lambda. openFiles() then loads a project -- seconds of work on
// a large one -- and openAndAddProject() 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 instance exits, the
// connection closes and the QLocalSocket is deleted. When the
// dialog is dismissed and the stack unwinds, QMetaObject::
// activate() continues emitting on the freed sender and the
// process dies. Reported with a backtrace on PR #861;
// reproduced on Qt 6.10.2 by dismissing the dialog, which is the
// step that makes it fail -- leaving it open never unwinds.
//
// A zero-timer returns to the event loop first, so the socket
// stack is fully unwound before any of this runs.
const QETArguments deferred_args{args_list};
QTimer::singleShot(0, this, [this, deferred_args]() {
openFiles(deferred_args);
});
}
}
+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();
}
/**
+16 -1
View File
@@ -574,8 +574,23 @@ void Conductor::paint(QPainter *painter, const QStyleOptionGraphicsItem *options
painter -> setPen(final_conductor_pen);
painter -> setBrush(junction_brush);
painter -> setRenderHint(QPainter::Antialiasing, true);
// The junction dot has to read as a dot on top of the conductor
// that carries it, so it scales with the conductor width instead
// of being a fixed 3.0 across: on a wide conductor a 3.0 dot is
// narrower than the line and simply disappears (bugtracker #108).
//
// Floored at the historic 3.0 so nothing changes for the default
// width of 1.0 or anything thinner -- only the wide conductors
// the report is about are affected. m_properties.cond_size is
// used rather than the pen, whose width is inflated by 4 while
// the mouse is over the conductor.
const qreal junction_diameter = qMax(3.0, 3.0 * m_properties.cond_size);
const qreal junction_radius = junction_diameter / 2.0;
foreach(QPointF point, junctions_list) {
painter -> drawEllipse(QRectF(point.x() - 1.5, point.y() - 1.5, 3.0, 3.0));
painter -> drawEllipse(QRectF(point.x() - junction_radius,
point.y() - junction_radius,
junction_diameter,
junction_diameter));
}
}
+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;
+77 -32
View File
@@ -29,6 +29,7 @@
#include <QLineEdit>
#include <QPushButton>
#include <QLabel>
#include <QCheckBox>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
@@ -36,6 +37,7 @@
#include <QAction>
#include <QFont>
#include <QTimer>
#include <QSettings>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
@@ -56,12 +58,16 @@ PlcLinkWidget::PlcLinkWidget(Element *elmt, QWidget *parent)
main_layout->addWidget(m_unlink_pb, 0, 1);
main_layout->addWidget(m_show_this_pb, 0, 2);
// Row 1: Search field
// Row 1: Hide linked elements checkbox
m_hide_linked_cb = new QCheckBox(tr("Masquer les éléments connectés"), this);
main_layout->addWidget(m_hide_linked_cb, 1, 0, 1, 3);
// Row 2: Search field
m_search_field = new QLineEdit(this);
m_search_field->setPlaceholderText(tr("Recherche"));
main_layout->addWidget(m_search_field, 1, 0, 1, 3);
main_layout->addWidget(m_search_field, 2, 0, 1, 3);
// Row 2: Tree widget
// Row 3: Tree widget
m_tree_widget = new QTreeWidget(this);
m_tree_widget->setHeaderLabels({
tr("Label"), tr("Type"), tr("Adresse"),
@@ -78,9 +84,9 @@ PlcLinkWidget::PlcLinkWidget(Element *elmt, QWidget *parent)
m_tree_widget->header()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
m_tree_widget->header()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
m_tree_widget->header()->setSectionResizeMode(5, QHeaderView::ResizeToContents);
main_layout->addWidget(m_tree_widget, 2, 0, 1, 3);
main_layout->addWidget(m_tree_widget, 3, 0, 1, 3);
// Row 3: Hidden masters note
// Row 4: Hidden masters note
m_hidden_masters_label = new QLabel(
tr("Remarque : les éléments maîtres ayant atteint leur nombre maximal "
"d'esclaves sont masqués."), this);
@@ -89,9 +95,9 @@ PlcLinkWidget::PlcLinkWidget(Element *elmt, QWidget *parent)
italic_font.setItalic(true);
m_hidden_masters_label->setFont(italic_font);
m_hidden_masters_label->hide();
main_layout->addWidget(m_hidden_masters_label, 3, 0, 1, 3);
main_layout->addWidget(m_hidden_masters_label, 4, 0, 1, 3);
main_layout->setRowStretch(2, 1);
main_layout->setRowStretch(3, 1);
setMinimumWidth(500);
@@ -104,6 +110,12 @@ PlcLinkWidget::PlcLinkWidget(Element *elmt, QWidget *parent)
this, &PlcLinkWidget::on_m_search_field_textEdited);
connect(m_tree_widget, &QTreeWidget::customContextMenuRequested,
this, &PlcLinkWidget::on_m_tree_widget_customContextMenuRequested);
connect(m_hide_linked_cb, &QCheckBox::toggled,
this, &PlcLinkWidget::on_m_hide_linked_cb_toggled);
QSettings settings;
m_hide_linked_cb->setChecked(
settings.value(QStringLiteral("plclinkwidget/hideLinked"), false).toBool());
if (elmt)
setElement(elmt);
@@ -163,6 +175,7 @@ void PlcLinkWidget::buildPlcTree()
{
m_tree_widget->clear();
m_io_entry_hash.clear();
m_linked_children.clear();
if (!m_element || !m_element->diagram() || !m_element->diagram()->project())
return;
@@ -216,6 +229,7 @@ void PlcLinkWidget::buildPlcTree()
parent_item->setExpanded(false);
// Add child items for each IO entry
bool all_children_hidden = true;
for (int i = 0; i < plc_data.ios.size(); ++i) {
const auto &io = plc_data.ios.at(i);
auto *child_item = new QTreeWidgetItem(parent_item);
@@ -233,36 +247,54 @@ void PlcLinkWidget::buildPlcTree()
entry.ioIndex = i;
m_io_entry_hash.insert(child_item, entry);
// If this IO is already linked to a slave, grey it out and strike through
// If this IO is already linked to a slave
if (used_io_indices.contains(i)) {
QFont strike_font = child_item->font(0);
strike_font.setStrikeOut(true);
child_item->setFont(0, strike_font);
child_item->setFont(1, strike_font);
child_item->setFont(2, strike_font);
child_item->setFont(3, strike_font);
child_item->setFont(4, strike_font);
child_item->setFont(5, strike_font);
m_linked_children.insert(child_item);
if (m_hide_linked_cb->isChecked()) {
child_item->setHidden(true);
} else {
all_children_hidden = false;
QFont strike_font = child_item->font(0);
strike_font.setStrikeOut(true);
child_item->setFont(0, strike_font);
child_item->setFont(1, strike_font);
child_item->setFont(2, strike_font);
child_item->setFont(3, strike_font);
child_item->setFont(4, strike_font);
child_item->setFont(5, strike_font);
QBrush grey_brush(Qt::gray);
for (int col = 0; col < 6; ++col)
child_item->setForeground(col, grey_brush);
QBrush grey_brush(Qt::gray);
for (int col = 0; col < 6; ++col)
child_item->setForeground(col, grey_brush);
// Show which slave is linked
for (Element *linked : elmt->linkedElements()) {
if (elmt->groupIndexForElement(linked) == i) {
child_item->setToolTip(0,
tr("Lié à: %1").arg(linked->actualLabel()));
break;
// Show which slave is linked
for (Element *linked : elmt->linkedElements()) {
if (elmt->groupIndexForElement(linked) == i) {
child_item->setToolTip(0,
tr("Lié à: %1").arg(linked->actualLabel()));
break;
}
}
}
child_item->setFlags(child_item->flags() & ~Qt::ItemIsSelectable);
child_item->setFlags(child_item->flags() & ~Qt::ItemIsSelectable);
}
} else {
all_children_hidden = false;
}
}
// If checkbox is on and every child is linked (hidden), hide the master too
if (m_hide_linked_cb->isChecked() && all_children_hidden) {
parent_item->setHidden(true);
}
}
}
bool PlcLinkWidget::isChildLinked(QTreeWidgetItem *child) const
{
return m_linked_children.contains(child);
}
void PlcLinkWidget::hideButtons()
{
m_label->hide();
@@ -296,15 +328,21 @@ void PlcLinkWidget::on_m_search_field_textEdited(const QString &text)
}
}
}
child->setHidden(!match);
if (match) any_child_visible = true;
bool hidden = m_hide_linked_cb->isChecked() && isChildLinked(child);
child->setHidden(!match || hidden);
if (match && !hidden) any_child_visible = true;
}
// Also check if parent label matches
if (!text.isEmpty() && parent->text(0).contains(text, Qt::CaseInsensitive)) {
any_child_visible = true;
for (int j = 0; j < parent->childCount(); ++j)
parent->child(j)->setHidden(false);
for (int j = 0; j < parent->childCount(); ++j) {
QTreeWidgetItem *child = parent->child(j);
bool hidden = m_hide_linked_cb->isChecked() && isChildLinked(child);
if (!hidden) {
any_child_visible = true;
child->setHidden(false);
}
}
}
parent->setHidden(!any_child_visible);
@@ -383,3 +421,10 @@ void PlcLinkWidget::on_m_show_this_pb_clicked()
m_element->diagram()->showMe();
m_element->setHighlighted(true);
}
void PlcLinkWidget::on_m_hide_linked_cb_toggled(bool checked)
{
QSettings settings;
settings.setValue(QStringLiteral("plclinkwidget/hideLinked"), checked);
buildPlcTree();
}
+5
View File
@@ -29,6 +29,7 @@ class QTreeWidget;
class QLineEdit;
class QPushButton;
class QLabel;
class QCheckBox;
class Element;
/**
@@ -58,17 +59,20 @@ class PlcLinkWidget : public AbstractElementPropertiesEditorWidget
void buildPlcTree();
void hideButtons();
void showButtons();
bool isChildLinked(QTreeWidgetItem *child) const;
private slots:
void on_m_search_field_textEdited(const QString &text);
void on_m_tree_widget_customContextMenuRequested(const QPoint &pos);
void on_m_unlink_pb_clicked();
void on_m_show_this_pb_clicked();
void on_m_hide_linked_cb_toggled(bool checked);
private:
QLabel *m_label{nullptr};
QPushButton *m_unlink_pb{nullptr};
QPushButton *m_show_this_pb{nullptr};
QCheckBox *m_hide_linked_cb{nullptr};
QLineEdit *m_search_field{nullptr};
QTreeWidget *m_tree_widget{nullptr};
QLabel *m_hidden_masters_label{nullptr};
@@ -79,6 +83,7 @@ class PlcLinkWidget : public AbstractElementPropertiesEditorWidget
int ioIndex = -1;
};
QHash<QTreeWidgetItem*, PlcIoEntry> m_io_entry_hash;
QSet<QTreeWidgetItem*> m_linked_children;
Element *m_element_to_link = nullptr;
int m_pending_io_index = -1;
+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
+20
View File
@@ -100,3 +100,23 @@ add_executable(
add_test(NAME tst_smart_device COMMAND tst_smart_device)
target_include_directories(tst_smart_device PRIVATE ${QET_DIR}/sources)
target_link_libraries(tst_smart_device PRIVATE Qt::Test Qt::Sql)
# qet.cpp carries the SingleApplication argument wire format
# (joinWithSpaces/splitWithSpaces); it pulls in qeticons and shortcutmanager,
# so those are compiled alongside rather than linking the whole application.
add_executable(
tst_qetstrings
tst_qetstrings.cpp
${QET_DIR}/sources/qet.cpp
${QET_DIR}/sources/qeticons.cpp
${QET_DIR}/sources/shortcutmanager.cpp)
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"
+54
View File
@@ -0,0 +1,54 @@
#include <QtTest>
#include "qet.h"
/**
QET::joinWithSpaces() / QET::splitWithSpaces() are the wire format for the
SingleApplication message a secondary instance sends to the running one
(main.cpp: "launched-with-args: " + joinWithSpaces(...), received by
QETApp::receiveMessage()). If the round trip loses arguments, opening a
file while QET is already running silently does nothing.
splitWithSpaces() used to split on QRegularExpression("[^\\]?(?:\\\\)* "),
which is not a valid pattern: "[^\\]" opens a character class whose "\\]"
is an escaped bracket, so the class never closes. QRegularExpression
reported isValid() == false and QString::split() returned an empty list for
every input -- bugtracker #248.
*/
class tst_qetstrings : public QObject
{
Q_OBJECT
private slots:
void roundTrips_data()
{
QTest::addColumn<QStringList>("input");
QTest::newRow("single plain") << QStringList{"one.qet"};
QTest::newRow("two plain") << QStringList{"one.qet", "two.qet"};
QTest::newRow("space in name") << QStringList{"my file.qet"};
QTest::newRow("space then plain") << QStringList{"my file.qet", "other.qet"};
QTest::newRow("spaces in path") << QStringList{"/home/a b/c d.qet", "/tmp/x.qet"};
QTest::newRow("backslash in name") << QStringList{"back\\slash.qet"};
QTest::newRow("trailing backslash") << QStringList{"trailing\\"};
QTest::newRow("mixed") << QStringList{"a b", "c\\d", "e"};
}
/// What the IPC actually needs: whatever went in comes back out.
void roundTrips()
{
QFETCH(QStringList, input);
QCOMPARE(QET::splitWithSpaces(QET::joinWithSpaces(input)), input);
}
/// The specific regression: the old implementation returned an empty list
/// for every input, so this passed nothing on to openFiles().
void splitIsNotEmptyForPlainArguments()
{
QVERIFY(!QET::splitWithSpaces(QStringLiteral("one.qet")).isEmpty());
QCOMPARE(QET::splitWithSpaces(QStringLiteral("a.qet b.qet")).count(), 2);
}
};
QTEST_APPLESS_MAIN(tst_qetstrings)
#include "tst_qetstrings.moc"