mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-20 15:24:14 +02:00
Merge remote-tracking branch 'origin/master' into fix-crossref-textfield
This commit is contained in:
+17
-13
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
Submodule elements updated: 74bdb77844...99c4d51b2c
+1463
-1707
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
Executable
+356
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user