mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-20 15:24:14 +02:00
Compare commits
13 Commits
199444b6db
...
fd38f55724
| Author | SHA1 | Date | |
|---|---|---|---|
| fd38f55724 | |||
| 85ed1b8a2a | |||
| 607eb4b1ea | |||
| 1e124450f2 | |||
| 4d70fcf35c | |||
| 55c2c0df9d | |||
| b94b244919 | |||
| bd6bed8d61 | |||
| 6a2b3973bc | |||
| d9db6e59e7 | |||
| 22469813fe | |||
| 88823ea35f | |||
| 9363e9bc2e |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -150,7 +150,10 @@ class Diagram : public QGraphicsScene
|
||||
void wheelEvent (QGraphicsSceneWheelEvent *event) override;
|
||||
void keyPressEvent (QKeyEvent *event) override;
|
||||
void keyReleaseEvent (QKeyEvent *) override;
|
||||
bool event(QEvent *event) override;
|
||||
|
||||
private:
|
||||
void selectNextItem(bool forward);
|
||||
|
||||
public:
|
||||
void correctTextPos(Element* elmt);
|
||||
@@ -287,6 +290,8 @@ class Diagram : public QGraphicsScene
|
||||
void selectAll();
|
||||
void deselectAll();
|
||||
void invertSelection();
|
||||
void selectAllConductors();
|
||||
void selectAllTextFields();
|
||||
|
||||
signals:
|
||||
void showDiagram (Diagram *);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "diagrameventaddpaste.h"
|
||||
|
||||
#include "../diagram.h"
|
||||
#include "../diagramcommands.h"
|
||||
#include "../qetapp.h"
|
||||
#include "../qetdiagrameditor.h"
|
||||
#include "../qetgraphicsitem/conductor.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QGraphicsSceneMouseEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QStatusBar>
|
||||
|
||||
/**
|
||||
@brief DiagramEventAddPaste::DiagramEventAddPaste
|
||||
@param diagram : diagram to paste into
|
||||
@param start_pos : where the pasted items first appear, in scene
|
||||
coordinates -- normally the cursor
|
||||
*/
|
||||
DiagramEventAddPaste::DiagramEventAddPaste(Diagram *diagram, const QPointF &start_pos) :
|
||||
DiagramEventInterface(diagram)
|
||||
{
|
||||
//DiagramEventInterface::init() is called by Diagram::setEventInterface
|
||||
//only when it is replacing an earlier interface, so call it here as
|
||||
//DiagramEventAddMacro does.
|
||||
init();
|
||||
|
||||
const QString clipboard_text = QApplication::clipboard()->text();
|
||||
if (clipboard_text.isEmpty()) return;
|
||||
|
||||
QDomDocument document_xml;
|
||||
if (!document_xml.setContent(clipboard_text)) return;
|
||||
|
||||
m_diagram->fromXml(document_xml, Diagram::snapToGrid(start_pos), false, &m_content);
|
||||
if (!m_content.count()) return;
|
||||
|
||||
//Remember where each item sits relative to the group's top left, so a
|
||||
//move is one assignment per item rather than an accumulated delta.
|
||||
QRectF group_rect;
|
||||
const QList<QGraphicsItem *> movable = m_content.items(MovableItems);
|
||||
for (auto *item : movable) {
|
||||
group_rect = group_rect.united(item->mapToScene(item->boundingRect()).boundingRect());
|
||||
}
|
||||
const QPointF top_left = group_rect.topLeft();
|
||||
for (auto *item : movable) {
|
||||
m_relative_pos.insert(item, item->pos() - top_left);
|
||||
}
|
||||
|
||||
m_diagram->clearSelection();
|
||||
for (auto *item : movable) {
|
||||
item->setSelected(true);
|
||||
}
|
||||
|
||||
if (!m_diagram->views().isEmpty()) {
|
||||
if (const auto qde = QETApp::diagramEditorAncestorOf(m_diagram->views().at(0))) {
|
||||
m_status_bar = qde->statusBar();
|
||||
}
|
||||
}
|
||||
showHint();
|
||||
|
||||
m_running = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramEventAddPaste::~DiagramEventAddPaste
|
||||
If the placement never finished -- the editor closed, or another tool took
|
||||
over -- the items are still on the folio with nothing on the undo stack to
|
||||
account for them, so take them away.
|
||||
*/
|
||||
DiagramEventAddPaste::~DiagramEventAddPaste()
|
||||
{
|
||||
if (!m_finished && m_diagram) {
|
||||
cancel();
|
||||
}
|
||||
if (m_status_bar) {
|
||||
m_status_bar->clearMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramEventAddPaste::clipboardHasDiagram
|
||||
@return true if the clipboard holds a diagram fragment
|
||||
*/
|
||||
bool DiagramEventAddPaste::clipboardHasDiagram()
|
||||
{
|
||||
return Diagram::clipboardMayContainDiagram();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramEventAddPaste::init
|
||||
Suppress the context menu while placing, so a right click can cancel
|
||||
instead of opening a menu over the items being positioned.
|
||||
*/
|
||||
void DiagramEventAddPaste::init()
|
||||
{
|
||||
if (!m_diagram) return;
|
||||
const auto views = m_diagram->views();
|
||||
for (auto *view : views) {
|
||||
view->setContextMenuPolicy(Qt::NoContextMenu);
|
||||
}
|
||||
}
|
||||
|
||||
void DiagramEventAddPaste::showHint()
|
||||
{
|
||||
if (m_status_bar) {
|
||||
m_status_bar->showMessage(
|
||||
tr("Cliquez pour poser le collage, Échap ou clic droit pour annuler",
|
||||
"status bar tip while positioning a paste"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramEventAddPaste::moveTo
|
||||
Put the group's top left corner at @a scene_pos, snapped to the grid.
|
||||
*/
|
||||
void DiagramEventAddPaste::moveTo(const QPointF &scene_pos)
|
||||
{
|
||||
const QPointF anchor = Diagram::snapToGrid(scene_pos);
|
||||
for (auto it = m_relative_pos.constBegin() ; it != m_relative_pos.constEnd() ; ++it) {
|
||||
if (it.key()) {
|
||||
it.key()->setPos(anchor + it.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DiagramEventAddPaste::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
if (!m_running) return;
|
||||
moveTo(event->scenePos());
|
||||
event->setAccepted(true);
|
||||
}
|
||||
|
||||
void DiagramEventAddPaste::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
if (!m_running) return;
|
||||
//Swallowed so the press cannot start a rubber band or drag an item
|
||||
//out of the group; the release is what decides.
|
||||
event->setAccepted(true);
|
||||
}
|
||||
|
||||
void DiagramEventAddPaste::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
if (!m_running) return;
|
||||
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
moveTo(event->scenePos());
|
||||
commit();
|
||||
} else if (event->button() == Qt::RightButton) {
|
||||
cancel();
|
||||
}
|
||||
event->setAccepted(true);
|
||||
}
|
||||
|
||||
void DiagramEventAddPaste::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
if (!m_running) return;
|
||||
|
||||
switch (event->key()) {
|
||||
case Qt::Key_Escape:
|
||||
cancel();
|
||||
event->setAccepted(true);
|
||||
break;
|
||||
//Return and Enter drop the paste where it stands, so the whole
|
||||
//operation can be completed without a mouse.
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
commit();
|
||||
event->setAccepted(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramEventAddPaste::commit
|
||||
Hand the items to the undo stack where they stand.
|
||||
|
||||
PasteDiagramCommand's first redo() does not add the items to the scene --
|
||||
it assumes they are already there, which is what Diagram::fromXml did when
|
||||
this started. So pushing it here adopts them rather than duplicating them.
|
||||
*/
|
||||
void DiagramEventAddPaste::commit()
|
||||
{
|
||||
if (m_finished || !m_diagram) return;
|
||||
m_finished = true;
|
||||
m_running = false;
|
||||
|
||||
m_diagram->undoStack().push(new PasteDiagramCommand(m_diagram, m_content));
|
||||
emit finish();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramEventAddPaste::cancel
|
||||
Take the items back off the folio. Nothing was pushed to the undo stack,
|
||||
so there is nothing to undo afterwards.
|
||||
*/
|
||||
void DiagramEventAddPaste::cancel()
|
||||
{
|
||||
if (m_finished || !m_diagram) return;
|
||||
m_finished = true;
|
||||
m_running = false;
|
||||
|
||||
//Conductors first: they hold pointers to the terminals of the
|
||||
//elements below, so removing an element out from under one would
|
||||
//leave it pointing at freed memory for as long as it is still in the
|
||||
//scene.
|
||||
const QList<Conductor *> conductors = m_content.conductors(DiagramContent::AnyConductor);
|
||||
for (auto *conductor : conductors) {
|
||||
m_diagram->removeItem(conductor);
|
||||
delete conductor;
|
||||
}
|
||||
|
||||
const QList<QGraphicsItem *> rest = m_content.items(MovableItems);
|
||||
for (auto *item : rest) {
|
||||
m_diagram->removeItem(item);
|
||||
delete item;
|
||||
}
|
||||
|
||||
m_content.clear();
|
||||
m_relative_pos.clear();
|
||||
emit finish();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef DIAGRAMEVENTADDPASTE_H
|
||||
#define DIAGRAMEVENTADDPASTE_H
|
||||
|
||||
#include "diagrameventinterface.h"
|
||||
#include "../diagramcontent.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QPointer>
|
||||
|
||||
class QStatusBar;
|
||||
|
||||
/**
|
||||
@brief The DiagramEventAddPaste class
|
||||
Paste the clipboard as a placement you can still move.
|
||||
|
||||
The pasted items are added straight away and follow the cursor until a
|
||||
left click drops them; Escape or a right click takes them away again.
|
||||
This is the same interaction as placing a new element, so a paste behaves
|
||||
like every other "put something on the folio" action.
|
||||
|
||||
The items are the real ones from the start, not a preview: Diagram::fromXml
|
||||
creates them, this class moves them, and PasteDiagramCommand is pushed only
|
||||
once they are dropped. That keeps one copy of the paste logic rather than
|
||||
two, and means a cancelled paste leaves nothing on the undo stack.
|
||||
*/
|
||||
class DiagramEventAddPaste : public DiagramEventInterface
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
///Items with a position of their own. Conductors are left out
|
||||
///deliberately: they are drawn from their terminals, so they
|
||||
///follow when the elements they attach to move.
|
||||
static const int MovableItems =
|
||||
DiagramContent::Elements
|
||||
| DiagramContent::TextFields
|
||||
| DiagramContent::Images
|
||||
| DiagramContent::Shapes
|
||||
| DiagramContent::Tables
|
||||
| DiagramContent::TerminalStrip;
|
||||
|
||||
DiagramEventAddPaste(Diagram *diagram, const QPointF &start_pos);
|
||||
~DiagramEventAddPaste() override;
|
||||
|
||||
void mouseMoveEvent (QGraphicsSceneMouseEvent *event) override;
|
||||
void mousePressEvent (QGraphicsSceneMouseEvent *event) override;
|
||||
void mouseReleaseEvent (QGraphicsSceneMouseEvent *event) override;
|
||||
void keyPressEvent (QKeyEvent *event) override;
|
||||
void init() override;
|
||||
|
||||
///@return true if the clipboard holds something this can paste.
|
||||
static bool clipboardHasDiagram();
|
||||
|
||||
private:
|
||||
void moveTo(const QPointF &scene_pos);
|
||||
void commit();
|
||||
void cancel();
|
||||
void showHint();
|
||||
|
||||
DiagramContent m_content;
|
||||
///Each movable item's position relative to the group's top left,
|
||||
///taken once so repeated moves cannot accumulate rounding drift.
|
||||
QHash<QGraphicsItem *, QPointF> m_relative_pos;
|
||||
QPointer<QStatusBar> m_status_bar;
|
||||
bool m_finished{false};
|
||||
};
|
||||
|
||||
#endif // DIAGRAMEVENTADDPASTE_H
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -113,3 +113,10 @@ add_executable(
|
||||
add_test(NAME tst_qetstrings COMMAND tst_qetstrings)
|
||||
target_include_directories(tst_qetstrings PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_qetstrings PRIVATE Qt::Test Qt::Widgets Qt::Xml)
|
||||
|
||||
add_executable(
|
||||
tst_menubarkeyboard
|
||||
tst_menubarkeyboard.cpp)
|
||||
add_test(NAME tst_menubarkeyboard COMMAND tst_menubarkeyboard)
|
||||
target_include_directories(tst_menubarkeyboard PRIVATE ${QET_DIR}/sources)
|
||||
target_link_libraries(tst_menubarkeyboard PRIVATE Qt::Test Qt::Widgets)
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user