feat(app): add main window with palette, property panel and settings
- Grouped node palette with SVG icons, drag-and-drop onto the canvas - Property panel: grouped typed editors (string, int, float, enum, catalogue single/multi pickers, color with auto-reset, value list), read-only solver results, undo-aware edits - Settings dialog persisted via QSettings: grid step, snapping, intersection arcs, color scheme - File new/open/save with dirty-check, bundled sample document, zoom and simulation controls, status bar with cursor position and zoom - --screenshot flag for headless smoke checks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a52f7bffbf
commit
18289b33e1
@ -34,3 +34,22 @@ add_library(diagscene STATIC
|
|||||||
scene/GridView.cpp
|
scene/GridView.cpp
|
||||||
)
|
)
|
||||||
target_link_libraries(diagscene PUBLIC diagcore Qt6::Widgets Qt6::Svg)
|
target_link_libraries(diagscene PUBLIC diagcore Qt6::Widgets Qt6::Svg)
|
||||||
|
|
||||||
|
# App: main window and panels.
|
||||||
|
add_library(diagapp STATIC
|
||||||
|
app/MainWindow.h
|
||||||
|
app/MainWindow.cpp
|
||||||
|
app/NodePalette.h
|
||||||
|
app/NodePalette.cpp
|
||||||
|
app/PropertyPanel.h
|
||||||
|
app/PropertyPanel.cpp
|
||||||
|
app/SettingsDialog.h
|
||||||
|
app/SettingsDialog.cpp
|
||||||
|
)
|
||||||
|
target_link_libraries(diagapp PUBLIC diagscene)
|
||||||
|
|
||||||
|
# Resources live on the executable: a static lib would drop the unreferenced
|
||||||
|
# resource-init object at link time.
|
||||||
|
qt_add_resources(APP_RESOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../resources/resources.qrc)
|
||||||
|
qt_add_executable(pipediagram main.cpp ${APP_RESOURCES})
|
||||||
|
target_link_libraries(pipediagram PRIVATE diagapp)
|
||||||
|
|||||||
258
src/app/MainWindow.cpp
Normal file
258
src/app/MainWindow.cpp
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
#include "MainWindow.h"
|
||||||
|
|
||||||
|
#include "NodePalette.h"
|
||||||
|
#include "PropertyPanel.h"
|
||||||
|
#include "core/FlowSolver.h"
|
||||||
|
#include "core/JsonIo.h"
|
||||||
|
#include "scene/DiagramScene.h"
|
||||||
|
#include "scene/GridView.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCloseEvent>
|
||||||
|
#include <QDockWidget>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMenuBar>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QStatusBar>
|
||||||
|
#include <QToolBar>
|
||||||
|
|
||||||
|
namespace diag {
|
||||||
|
|
||||||
|
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent)
|
||||||
|
{
|
||||||
|
setWindowTitle(tr("PipeDiagram"));
|
||||||
|
resize(1280, 800);
|
||||||
|
|
||||||
|
TypeRegistry& registry = TypeRegistry::instance();
|
||||||
|
if (registry.nodeTypes().isEmpty()) {
|
||||||
|
QString err;
|
||||||
|
if (!registry.loadFromFile(QStringLiteral(":/config/nodetypes.json"), &err))
|
||||||
|
QMessageBox::warning(this, tr("Configuration"),
|
||||||
|
tr("Failed to load node types: %1").arg(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
m_model = new NetworkModel(®istry, this);
|
||||||
|
m_undoStack = new QUndoStack(this);
|
||||||
|
m_scene = new DiagramScene(m_model, m_undoStack, this);
|
||||||
|
m_view = new GridView(m_scene, this);
|
||||||
|
setCentralWidget(m_view);
|
||||||
|
|
||||||
|
m_palette = new NodePalette(®istry, this);
|
||||||
|
auto* paletteDock = new QDockWidget(tr("Node palette"), this);
|
||||||
|
paletteDock->setObjectName(QStringLiteral("paletteDock"));
|
||||||
|
paletteDock->setWidget(m_palette);
|
||||||
|
addDockWidget(Qt::LeftDockWidgetArea, paletteDock);
|
||||||
|
|
||||||
|
m_properties = new PropertyPanel(this);
|
||||||
|
m_properties->setScene(m_scene);
|
||||||
|
auto* propsDock = new QDockWidget(tr("Properties"), this);
|
||||||
|
propsDock->setObjectName(QStringLiteral("propsDock"));
|
||||||
|
propsDock->setWidget(m_properties);
|
||||||
|
addDockWidget(Qt::RightDockWidgetArea, propsDock);
|
||||||
|
|
||||||
|
buildActions();
|
||||||
|
|
||||||
|
auto* cursorLabel = new QLabel;
|
||||||
|
auto* zoomLabel = new QLabel(QStringLiteral("100%"));
|
||||||
|
statusBar()->addPermanentWidget(cursorLabel);
|
||||||
|
statusBar()->addPermanentWidget(zoomLabel);
|
||||||
|
connect(m_view, &GridView::cursorMoved, this, [cursorLabel](QPointF p) {
|
||||||
|
cursorLabel->setText(QStringLiteral("x: %1 y: %2").arg(int(p.x())).arg(int(p.y())));
|
||||||
|
});
|
||||||
|
connect(m_view, &GridView::zoomChanged, this, [zoomLabel](double z) {
|
||||||
|
zoomLabel->setText(QStringLiteral("%1%").arg(int(z * 100)));
|
||||||
|
});
|
||||||
|
connect(m_scene, &DiagramScene::connectionMessage, this,
|
||||||
|
[this](const QString& text) { statusBar()->showMessage(text, 4000); });
|
||||||
|
|
||||||
|
m_settings.load();
|
||||||
|
applySettings(m_settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::buildActions()
|
||||||
|
{
|
||||||
|
auto* fileMenu = menuBar()->addMenu(tr("&File"));
|
||||||
|
auto* toolbar = addToolBar(tr("Main"));
|
||||||
|
toolbar->setObjectName(QStringLiteral("mainToolbar"));
|
||||||
|
|
||||||
|
QAction* newAct = fileMenu->addAction(tr("&New"), QKeySequence::New, this,
|
||||||
|
&MainWindow::newDocument);
|
||||||
|
QAction* openAct = fileMenu->addAction(tr("&Open…"), QKeySequence::Open, this,
|
||||||
|
&MainWindow::openDocument);
|
||||||
|
QAction* saveAct = fileMenu->addAction(tr("&Save"), QKeySequence::Save, this,
|
||||||
|
&MainWindow::saveDocument);
|
||||||
|
fileMenu->addAction(tr("Save &As…"), QKeySequence::SaveAs, this,
|
||||||
|
&MainWindow::saveDocumentAs);
|
||||||
|
fileMenu->addAction(tr("Load Sa&mple"), this, &MainWindow::loadSample);
|
||||||
|
fileMenu->addSeparator();
|
||||||
|
fileMenu->addAction(tr("&Quit"), QKeySequence::Quit, this, &QWidget::close);
|
||||||
|
|
||||||
|
auto* editMenu = menuBar()->addMenu(tr("&Edit"));
|
||||||
|
QAction* undoAct = m_undoStack->createUndoAction(this, tr("&Undo"));
|
||||||
|
undoAct->setShortcut(QKeySequence::Undo);
|
||||||
|
QAction* redoAct = m_undoStack->createRedoAction(this, tr("&Redo"));
|
||||||
|
redoAct->setShortcut(QKeySequence::Redo);
|
||||||
|
editMenu->addAction(undoAct);
|
||||||
|
editMenu->addAction(redoAct);
|
||||||
|
editMenu->addSeparator();
|
||||||
|
editMenu->addAction(tr("Cu&t"), QKeySequence::Cut, m_scene, &DiagramScene::cutSelection);
|
||||||
|
editMenu->addAction(tr("&Copy"), QKeySequence::Copy, m_scene,
|
||||||
|
&DiagramScene::copySelection);
|
||||||
|
editMenu->addAction(tr("&Paste"), QKeySequence::Paste, m_scene, &DiagramScene::paste);
|
||||||
|
QAction* deleteAct = editMenu->addAction(tr("&Delete"), QKeySequence::Delete, m_scene,
|
||||||
|
&DiagramScene::deleteSelection);
|
||||||
|
editMenu->addAction(tr("Select &All"), QKeySequence::SelectAll, m_scene,
|
||||||
|
&DiagramScene::selectAll);
|
||||||
|
editMenu->addSeparator();
|
||||||
|
editMenu->addAction(tr("Se&ttings…"), QKeySequence::Preferences, this,
|
||||||
|
&MainWindow::showSettings);
|
||||||
|
|
||||||
|
auto* viewMenu = menuBar()->addMenu(tr("&View"));
|
||||||
|
viewMenu->addAction(tr("Zoom &In"), QKeySequence::ZoomIn, m_view, &GridView::zoomIn);
|
||||||
|
viewMenu->addAction(tr("Zoom &Out"), QKeySequence::ZoomOut, m_view, &GridView::zoomOut);
|
||||||
|
QAction* fitAct = viewMenu->addAction(tr("Zoom to &Fit"), QKeySequence(Qt::Key_F), m_view,
|
||||||
|
&GridView::zoomToFit);
|
||||||
|
viewMenu->addAction(tr("&Reset Zoom"), QKeySequence(Qt::CTRL | Qt::Key_0), m_view,
|
||||||
|
&GridView::zoomReset);
|
||||||
|
|
||||||
|
auto* simMenu = menuBar()->addMenu(tr("&Simulation"));
|
||||||
|
m_simulateAction = simMenu->addAction(tr("&Run Flow Simulation"));
|
||||||
|
m_simulateAction->setCheckable(true);
|
||||||
|
m_simulateAction->setShortcut(QKeySequence(Qt::Key_F5));
|
||||||
|
connect(m_simulateAction, &QAction::toggled, this, &MainWindow::runSimulation);
|
||||||
|
|
||||||
|
toolbar->addAction(newAct);
|
||||||
|
toolbar->addAction(openAct);
|
||||||
|
toolbar->addAction(saveAct);
|
||||||
|
toolbar->addSeparator();
|
||||||
|
toolbar->addAction(undoAct);
|
||||||
|
toolbar->addAction(redoAct);
|
||||||
|
toolbar->addAction(deleteAct);
|
||||||
|
toolbar->addSeparator();
|
||||||
|
toolbar->addAction(fitAct);
|
||||||
|
toolbar->addAction(m_simulateAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::applySettings(const EditorSettings& settings)
|
||||||
|
{
|
||||||
|
m_settings = settings;
|
||||||
|
m_scene->setRouteConfig(settings.route);
|
||||||
|
m_scene->setTheme(Theme::make(settings.scheme));
|
||||||
|
settings.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::showSettings()
|
||||||
|
{
|
||||||
|
SettingsDialog dialog(m_settings, this);
|
||||||
|
if (dialog.exec() == QDialog::Accepted)
|
||||||
|
applySettings(dialog.settings());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MainWindow::maybeSave()
|
||||||
|
{
|
||||||
|
if (m_undoStack->isClean())
|
||||||
|
return true;
|
||||||
|
const auto answer = QMessageBox::question(
|
||||||
|
this, tr("Unsaved changes"), tr("The diagram has unsaved changes. Save them?"),
|
||||||
|
QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel);
|
||||||
|
if (answer == QMessageBox::Save) {
|
||||||
|
saveDocument();
|
||||||
|
return m_undoStack->isClean();
|
||||||
|
}
|
||||||
|
return answer == QMessageBox::Discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::newDocument()
|
||||||
|
{
|
||||||
|
if (!maybeSave())
|
||||||
|
return;
|
||||||
|
m_model->clear();
|
||||||
|
m_undoStack->clear();
|
||||||
|
m_currentFile.clear();
|
||||||
|
m_simulateAction->setChecked(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::openDocument()
|
||||||
|
{
|
||||||
|
if (!maybeSave())
|
||||||
|
return;
|
||||||
|
const QString path = QFileDialog::getOpenFileName(this, tr("Open diagram"), {},
|
||||||
|
tr("Diagrams (*.json)"));
|
||||||
|
if (path.isEmpty())
|
||||||
|
return;
|
||||||
|
QString err;
|
||||||
|
if (!JsonIo::load(m_model, path, &err)) {
|
||||||
|
QMessageBox::warning(this, tr("Open failed"), err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_undoStack->clear();
|
||||||
|
m_currentFile = path;
|
||||||
|
m_simulateAction->setChecked(false);
|
||||||
|
m_view->zoomToFit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::saveDocument()
|
||||||
|
{
|
||||||
|
if (m_currentFile.isEmpty()) {
|
||||||
|
saveDocumentAs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QString err;
|
||||||
|
if (!JsonIo::save(*m_model, m_currentFile, &err))
|
||||||
|
QMessageBox::warning(this, tr("Save failed"), err);
|
||||||
|
else
|
||||||
|
m_undoStack->setClean();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::saveDocumentAs()
|
||||||
|
{
|
||||||
|
const QString path = QFileDialog::getSaveFileName(this, tr("Save diagram"), {},
|
||||||
|
tr("Diagrams (*.json)"));
|
||||||
|
if (path.isEmpty())
|
||||||
|
return;
|
||||||
|
m_currentFile = path;
|
||||||
|
saveDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::loadSample()
|
||||||
|
{
|
||||||
|
if (!maybeSave())
|
||||||
|
return;
|
||||||
|
QString err;
|
||||||
|
if (!JsonIo::load(m_model, QStringLiteral(":/samples/waterworks.json"), &err)) {
|
||||||
|
QMessageBox::warning(this, tr("Sample"), err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_undoStack->clear();
|
||||||
|
m_currentFile.clear();
|
||||||
|
m_view->zoomToFit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::runSimulation(bool enabled)
|
||||||
|
{
|
||||||
|
if (enabled) {
|
||||||
|
// Mock calculation: distribute flow in every relation network, then
|
||||||
|
// publish rates to the read-only flowRate property and animate.
|
||||||
|
for (const Relation& rel : m_model->registry()->relations()) {
|
||||||
|
const FlowResult result = FlowSolver::solve(*m_model, rel.id);
|
||||||
|
if (!result.ok && !result.message.isEmpty())
|
||||||
|
statusBar()->showMessage(tr("%1: %2").arg(rel.name, result.message), 5000);
|
||||||
|
FlowSolver::apply(*m_model, result);
|
||||||
|
}
|
||||||
|
statusBar()->showMessage(tr("Flow simulation running"), 3000);
|
||||||
|
} else {
|
||||||
|
statusBar()->showMessage(tr("Flow simulation stopped"), 3000);
|
||||||
|
}
|
||||||
|
m_scene->setFlowAnimationEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::closeEvent(QCloseEvent* event)
|
||||||
|
{
|
||||||
|
if (maybeSave())
|
||||||
|
event->accept();
|
||||||
|
else
|
||||||
|
event->ignore();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace diag
|
||||||
51
src/app/MainWindow.h
Normal file
51
src/app/MainWindow.h
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "SettingsDialog.h"
|
||||||
|
|
||||||
|
#include <QMainWindow>
|
||||||
|
#include <QUndoStack>
|
||||||
|
|
||||||
|
namespace diag {
|
||||||
|
|
||||||
|
class DiagramScene;
|
||||||
|
class GridView;
|
||||||
|
class NetworkModel;
|
||||||
|
class NodePalette;
|
||||||
|
class PropertyPanel;
|
||||||
|
|
||||||
|
class MainWindow : public QMainWindow {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit MainWindow(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
DiagramScene* scene() const { return m_scene; }
|
||||||
|
NetworkModel* model() const { return m_model; }
|
||||||
|
|
||||||
|
void loadSample();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void closeEvent(QCloseEvent* event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void buildActions();
|
||||||
|
void applySettings(const EditorSettings& settings);
|
||||||
|
void newDocument();
|
||||||
|
void openDocument();
|
||||||
|
void saveDocument();
|
||||||
|
void saveDocumentAs();
|
||||||
|
bool maybeSave();
|
||||||
|
void runSimulation(bool enabled);
|
||||||
|
void showSettings();
|
||||||
|
|
||||||
|
NetworkModel* m_model;
|
||||||
|
QUndoStack* m_undoStack;
|
||||||
|
DiagramScene* m_scene;
|
||||||
|
GridView* m_view;
|
||||||
|
NodePalette* m_palette;
|
||||||
|
PropertyPanel* m_properties;
|
||||||
|
EditorSettings m_settings;
|
||||||
|
QString m_currentFile;
|
||||||
|
QAction* m_simulateAction = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace diag
|
||||||
82
src/app/NodePalette.cpp
Normal file
82
src/app/NodePalette.cpp
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
#include "NodePalette.h"
|
||||||
|
|
||||||
|
#include "core/TypeRegistry.h"
|
||||||
|
#include "scene/GridView.h"
|
||||||
|
|
||||||
|
#include <QDrag>
|
||||||
|
#include <QMimeData>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QSvgRenderer>
|
||||||
|
|
||||||
|
namespace diag {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QIcon iconFor(const NodeType* type)
|
||||||
|
{
|
||||||
|
QSvgRenderer renderer(type->svgPath);
|
||||||
|
QPixmap pm(32, 32);
|
||||||
|
pm.fill(Qt::transparent);
|
||||||
|
if (renderer.isValid()) {
|
||||||
|
QPainter p(&pm);
|
||||||
|
renderer.render(&p, QRectF(0, 0, 32, 32));
|
||||||
|
}
|
||||||
|
return QIcon(pm);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
NodePalette::NodePalette(TypeRegistry* registry, QWidget* parent)
|
||||||
|
: QTreeWidget(parent), m_registry(registry)
|
||||||
|
{
|
||||||
|
setHeaderHidden(true);
|
||||||
|
setDragEnabled(true);
|
||||||
|
setIconSize({28, 28});
|
||||||
|
setRootIsDecorated(false);
|
||||||
|
setIndentation(12);
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NodePalette::reload()
|
||||||
|
{
|
||||||
|
clear();
|
||||||
|
QHash<QString, QTreeWidgetItem*> groups;
|
||||||
|
for (const QString& group : m_registry->nodeGroups()) {
|
||||||
|
auto* item = new QTreeWidgetItem(this, {group});
|
||||||
|
item->setFlags(Qt::ItemIsEnabled);
|
||||||
|
QFont f = item->font(0);
|
||||||
|
f.setBold(true);
|
||||||
|
item->setFont(0, f);
|
||||||
|
groups.insert(group, item);
|
||||||
|
}
|
||||||
|
for (const NodeType* type : m_registry->nodeTypes()) {
|
||||||
|
auto* item = new QTreeWidgetItem(groups.value(type->group), {type->name});
|
||||||
|
item->setIcon(0, iconFor(type));
|
||||||
|
item->setData(0, Qt::UserRole, type->id);
|
||||||
|
item->setToolTip(0, QStringLiteral("%1 — %2 port(s). Drag onto the canvas.")
|
||||||
|
.arg(type->name)
|
||||||
|
.arg(type->ports.size()));
|
||||||
|
}
|
||||||
|
expandAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
void NodePalette::startDrag(Qt::DropActions)
|
||||||
|
{
|
||||||
|
QTreeWidgetItem* item = currentItem();
|
||||||
|
if (!item)
|
||||||
|
return;
|
||||||
|
const QString typeId = item->data(0, Qt::UserRole).toString();
|
||||||
|
if (typeId.isEmpty())
|
||||||
|
return; // group header
|
||||||
|
|
||||||
|
auto* mime = new QMimeData();
|
||||||
|
mime->setData(QLatin1String(GridView::kNodeTypeMime), typeId.toUtf8());
|
||||||
|
|
||||||
|
auto* drag = new QDrag(this);
|
||||||
|
drag->setMimeData(mime);
|
||||||
|
drag->setPixmap(item->icon(0).pixmap(32, 32));
|
||||||
|
drag->setHotSpot({16, 16});
|
||||||
|
drag->exec(Qt::CopyAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace diag
|
||||||
25
src/app/NodePalette.h
Normal file
25
src/app/NodePalette.h
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QTreeWidget>
|
||||||
|
|
||||||
|
namespace diag {
|
||||||
|
|
||||||
|
class TypeRegistry;
|
||||||
|
|
||||||
|
// Grouped list of node types with SVG icons; items are dragged onto the
|
||||||
|
// canvas (mime: GridView::kNodeTypeMime carrying the type id).
|
||||||
|
class NodePalette : public QTreeWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit NodePalette(TypeRegistry* registry, QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
void reload();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void startDrag(Qt::DropActions supportedActions) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
TypeRegistry* m_registry;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace diag
|
||||||
303
src/app/PropertyPanel.cpp
Normal file
303
src/app/PropertyPanel.cpp
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
#include "PropertyPanel.h"
|
||||||
|
|
||||||
|
#include "scene/Commands.h"
|
||||||
|
#include "scene/DiagramScene.h"
|
||||||
|
#include "scene/EdgeItem.h"
|
||||||
|
#include "scene/NodeItem.h"
|
||||||
|
#include "core/NetworkModel.h"
|
||||||
|
|
||||||
|
#include <QColorDialog>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDoubleSpinBox>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QGroupBox>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QListWidget>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSpinBox>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace diag {
|
||||||
|
|
||||||
|
PropertyPanel::PropertyPanel(QWidget* parent) : QScrollArea(parent)
|
||||||
|
{
|
||||||
|
setWidgetResizable(true);
|
||||||
|
setMinimumWidth(260);
|
||||||
|
showPlaceholder(tr("Select a node or an edge\nto edit its properties"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void PropertyPanel::setScene(DiagramScene* scene)
|
||||||
|
{
|
||||||
|
if (m_scene)
|
||||||
|
disconnect(m_scene, nullptr, this, nullptr);
|
||||||
|
m_scene = scene;
|
||||||
|
connect(scene, &DiagramScene::selectionChanged, this, &PropertyPanel::onSelectionChanged);
|
||||||
|
// Rebuild values (not widgets) when the selected element changes elsewhere
|
||||||
|
// (undo, solver run, ...).
|
||||||
|
connect(scene->model(), &NetworkModel::nodeChanged, this, [this](QUuid id) {
|
||||||
|
if (m_target == Target::Node && id == m_id && !m_updating)
|
||||||
|
rebuild();
|
||||||
|
});
|
||||||
|
connect(scene->model(), &NetworkModel::edgeChanged, this, [this](QUuid id) {
|
||||||
|
if (m_target == Target::Edge && id == m_id && !m_updating)
|
||||||
|
rebuild();
|
||||||
|
});
|
||||||
|
connect(scene->model(), &NetworkModel::flowChanged, this, [this](QUuid id) {
|
||||||
|
if (m_target == Target::Edge && id == m_id)
|
||||||
|
rebuild();
|
||||||
|
});
|
||||||
|
onSelectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PropertyPanel::onSelectionChanged()
|
||||||
|
{
|
||||||
|
m_target = Target::None;
|
||||||
|
m_id = {};
|
||||||
|
if (m_scene) {
|
||||||
|
const auto items = m_scene->selectedItems();
|
||||||
|
if (items.size() == 1) {
|
||||||
|
if (auto* n = dynamic_cast<NodeItem*>(items.first())) {
|
||||||
|
m_target = Target::Node;
|
||||||
|
m_id = n->nodeId();
|
||||||
|
} else if (auto* e = dynamic_cast<EdgeItem*>(items.first())) {
|
||||||
|
m_target = Target::Edge;
|
||||||
|
m_id = e->edgeId();
|
||||||
|
}
|
||||||
|
} else if (items.size() > 1) {
|
||||||
|
showPlaceholder(tr("%1 elements selected").arg(items.size()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (m_target == Target::None) {
|
||||||
|
showPlaceholder(tr("Select a node or an edge\nto edit its properties"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PropertyPanel::showPlaceholder(const QString& text)
|
||||||
|
{
|
||||||
|
auto* placeholder = new QLabel(text);
|
||||||
|
placeholder->setAlignment(Qt::AlignCenter);
|
||||||
|
placeholder->setWordWrap(true);
|
||||||
|
placeholder->setStyleSheet(QStringLiteral("color: gray;"));
|
||||||
|
setWidget(placeholder);
|
||||||
|
m_content = placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant PropertyPanel::currentValue(const QString& key) const
|
||||||
|
{
|
||||||
|
NetworkModel* model = m_scene->model();
|
||||||
|
if (m_target == Target::Node)
|
||||||
|
return model->nodeProperty(m_id, key);
|
||||||
|
// Solver result lives on the edge itself, exposed via the read-only spec.
|
||||||
|
if (key == QLatin1String("flowRate")) {
|
||||||
|
if (const Edge* e = model->edge(m_id))
|
||||||
|
return e->flowRate;
|
||||||
|
}
|
||||||
|
return model->edgeProperty(m_id, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PropertyPanel::applyValue(const QString& key, const QVariant& value)
|
||||||
|
{
|
||||||
|
if (m_updating)
|
||||||
|
return;
|
||||||
|
m_updating = true;
|
||||||
|
m_scene->undoStack()->push(new SetPropertyCommand(
|
||||||
|
m_scene->model(),
|
||||||
|
m_target == Target::Node ? SetPropertyCommand::NodeTarget : SetPropertyCommand::EdgeTarget,
|
||||||
|
m_id, key, value));
|
||||||
|
m_updating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PropertyPanel::rebuild()
|
||||||
|
{
|
||||||
|
NetworkModel* model = m_scene->model();
|
||||||
|
QList<PropertySpec> specs;
|
||||||
|
QString header;
|
||||||
|
if (m_target == Target::Node) {
|
||||||
|
const Node* n = model->node(m_id);
|
||||||
|
if (!n)
|
||||||
|
return;
|
||||||
|
const NodeType* type = model->nodeTypeOf(m_id);
|
||||||
|
specs = model->registry()->nodeProperties(n->typeId);
|
||||||
|
header = tr("Node: %1").arg(type ? type->name : n->typeId);
|
||||||
|
} else {
|
||||||
|
const Edge* e = model->edge(m_id);
|
||||||
|
if (!e)
|
||||||
|
return;
|
||||||
|
const Relation* rel = model->registry()->relation(e->relation);
|
||||||
|
specs = model->registry()->edgeProperties(e->relation);
|
||||||
|
header = tr("Edge: %1 line").arg(rel ? rel->name : e->relation);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* content = new QWidget;
|
||||||
|
auto* layout = new QVBoxLayout(content);
|
||||||
|
layout->setContentsMargins(8, 8, 8, 8);
|
||||||
|
|
||||||
|
auto* title = new QLabel(header);
|
||||||
|
QFont f = title->font();
|
||||||
|
f.setBold(true);
|
||||||
|
title->setFont(f);
|
||||||
|
layout->addWidget(title);
|
||||||
|
|
||||||
|
// Group boxes in spec order, keeping first-seen group order.
|
||||||
|
QStringList groupOrder;
|
||||||
|
for (const auto& s : specs)
|
||||||
|
if (!groupOrder.contains(s.group))
|
||||||
|
groupOrder.append(s.group);
|
||||||
|
for (const QString& group : groupOrder) {
|
||||||
|
auto* box = new QGroupBox(group);
|
||||||
|
auto* form = new QFormLayout(box);
|
||||||
|
form->setLabelAlignment(Qt::AlignLeft);
|
||||||
|
for (const auto& spec : specs) {
|
||||||
|
if (spec.group != group)
|
||||||
|
continue;
|
||||||
|
QString label = spec.name;
|
||||||
|
if (!spec.unit.isEmpty())
|
||||||
|
label += QStringLiteral(", %1").arg(spec.unit);
|
||||||
|
form->addRow(label, createEditor(spec));
|
||||||
|
}
|
||||||
|
layout->addWidget(box);
|
||||||
|
}
|
||||||
|
layout->addStretch();
|
||||||
|
|
||||||
|
setWidget(content);
|
||||||
|
m_content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
QWidget* PropertyPanel::createEditor(const PropertySpec& spec)
|
||||||
|
{
|
||||||
|
const QVariant value = currentValue(spec.id);
|
||||||
|
const bool unbounded = spec.min == spec.max;
|
||||||
|
QWidget* editor = nullptr;
|
||||||
|
|
||||||
|
switch (spec.type) {
|
||||||
|
case PropertyType::String: {
|
||||||
|
auto* w = new QLineEdit(value.toString());
|
||||||
|
connect(w, &QLineEdit::editingFinished, this,
|
||||||
|
[this, spec, w] { applyValue(spec.id, w->text()); });
|
||||||
|
editor = w;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PropertyType::Int: {
|
||||||
|
auto* w = new QSpinBox;
|
||||||
|
w->setRange(unbounded ? -1000000000 : int(spec.min), unbounded ? 1000000000 : int(spec.max));
|
||||||
|
w->setValue(value.toInt());
|
||||||
|
connect(w, &QSpinBox::editingFinished, this,
|
||||||
|
[this, spec, w] { applyValue(spec.id, w->value()); });
|
||||||
|
editor = w;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PropertyType::Float: {
|
||||||
|
auto* w = new QDoubleSpinBox;
|
||||||
|
w->setRange(unbounded ? -1e12 : spec.min, unbounded ? 1e12 : spec.max);
|
||||||
|
w->setDecimals(3);
|
||||||
|
w->setValue(value.toDouble());
|
||||||
|
connect(w, &QDoubleSpinBox::editingFinished, this,
|
||||||
|
[this, spec, w] { applyValue(spec.id, w->value()); });
|
||||||
|
editor = w;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PropertyType::Enum: {
|
||||||
|
auto* w = new QComboBox;
|
||||||
|
w->addItems(spec.enumValues);
|
||||||
|
w->setCurrentText(value.toString());
|
||||||
|
connect(w, &QComboBox::activated, this,
|
||||||
|
[this, spec, w] { applyValue(spec.id, w->currentText()); });
|
||||||
|
editor = w;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PropertyType::CatalogueItem: {
|
||||||
|
auto* w = new QComboBox;
|
||||||
|
w->addItem(tr("—"), QString());
|
||||||
|
if (const Catalogue* cat = m_scene->model()->registry()->catalogue(spec.catalogueId))
|
||||||
|
for (const auto& item : cat->items)
|
||||||
|
w->addItem(item.name, item.id);
|
||||||
|
const int idx = w->findData(value.toString());
|
||||||
|
w->setCurrentIndex(qMax(0, idx));
|
||||||
|
connect(w, &QComboBox::activated, this,
|
||||||
|
[this, spec, w] { applyValue(spec.id, w->currentData().toString()); });
|
||||||
|
editor = w;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PropertyType::CatalogueItems: {
|
||||||
|
auto* w = new QListWidget;
|
||||||
|
w->setMaximumHeight(96);
|
||||||
|
const QStringList selected = value.toStringList();
|
||||||
|
if (const Catalogue* cat = m_scene->model()->registry()->catalogue(spec.catalogueId)) {
|
||||||
|
for (const auto& item : cat->items) {
|
||||||
|
auto* li = new QListWidgetItem(item.name, w);
|
||||||
|
li->setData(Qt::UserRole, item.id);
|
||||||
|
li->setFlags(li->flags() | Qt::ItemIsUserCheckable);
|
||||||
|
li->setCheckState(selected.contains(item.id) ? Qt::Checked : Qt::Unchecked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connect(w, &QListWidget::itemChanged, this, [this, spec, w] {
|
||||||
|
QStringList ids;
|
||||||
|
for (int i = 0; i < w->count(); ++i)
|
||||||
|
if (w->item(i)->checkState() == Qt::Checked)
|
||||||
|
ids.append(w->item(i)->data(Qt::UserRole).toString());
|
||||||
|
applyValue(spec.id, ids);
|
||||||
|
});
|
||||||
|
editor = w;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PropertyType::Color: {
|
||||||
|
auto* row = new QWidget;
|
||||||
|
auto* layout = new QHBoxLayout(row);
|
||||||
|
layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
auto* button = new QPushButton;
|
||||||
|
auto* clear = new QToolButton;
|
||||||
|
clear->setText(QStringLiteral("✕"));
|
||||||
|
clear->setToolTip(tr("Reset to automatic color"));
|
||||||
|
auto sync = [button](const QString& name) {
|
||||||
|
const bool valid = QColor::isValidColorName(name);
|
||||||
|
button->setText(valid ? name : QObject::tr("Auto"));
|
||||||
|
button->setStyleSheet(valid
|
||||||
|
? QStringLiteral("background-color: %1;").arg(name)
|
||||||
|
: QString());
|
||||||
|
};
|
||||||
|
sync(value.toString());
|
||||||
|
connect(button, &QPushButton::clicked, this, [this, spec, button, sync] {
|
||||||
|
const QColor initial(currentValue(spec.id).toString());
|
||||||
|
const QColor c = QColorDialog::getColor(initial.isValid() ? initial : Qt::gray,
|
||||||
|
this, tr("Choose color"));
|
||||||
|
if (c.isValid()) {
|
||||||
|
applyValue(spec.id, c.name());
|
||||||
|
sync(c.name());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
connect(clear, &QToolButton::clicked, this, [this, spec, sync] {
|
||||||
|
applyValue(spec.id, QString());
|
||||||
|
sync(QString());
|
||||||
|
});
|
||||||
|
layout->addWidget(button, 1);
|
||||||
|
layout->addWidget(clear);
|
||||||
|
editor = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case PropertyType::ValueList: {
|
||||||
|
auto* w = new QLineEdit(value.toStringList().join(QStringLiteral(", ")));
|
||||||
|
w->setPlaceholderText(tr("value1, value2, …"));
|
||||||
|
w->setToolTip(tr("Comma-separated list of values"));
|
||||||
|
connect(w, &QLineEdit::editingFinished, this, [this, spec, w] {
|
||||||
|
QStringList values;
|
||||||
|
for (const QString& part : w->text().split(QLatin1Char(',')))
|
||||||
|
if (!part.trimmed().isEmpty())
|
||||||
|
values.append(part.trimmed());
|
||||||
|
applyValue(spec.id, values);
|
||||||
|
});
|
||||||
|
editor = w;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.readOnly)
|
||||||
|
editor->setEnabled(false);
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace diag
|
||||||
42
src/app/PropertyPanel.h
Normal file
42
src/app/PropertyPanel.h
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/Types.h"
|
||||||
|
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
|
class QVBoxLayout;
|
||||||
|
|
||||||
|
namespace diag {
|
||||||
|
|
||||||
|
class DiagramScene;
|
||||||
|
|
||||||
|
// Right-side panel: grouped, typed editors for the selected node or edge.
|
||||||
|
// Edits are pushed to the undo stack; values refresh on model changes.
|
||||||
|
class PropertyPanel : public QScrollArea {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit PropertyPanel(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
void setScene(DiagramScene* scene);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void onSelectionChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class Target { None, Node, Edge };
|
||||||
|
|
||||||
|
void rebuild();
|
||||||
|
void showPlaceholder(const QString& text);
|
||||||
|
QVariant currentValue(const QString& key) const;
|
||||||
|
void applyValue(const QString& key, const QVariant& value);
|
||||||
|
QWidget* createEditor(const PropertySpec& spec);
|
||||||
|
|
||||||
|
DiagramScene* m_scene = nullptr;
|
||||||
|
Target m_target = Target::None;
|
||||||
|
QUuid m_id;
|
||||||
|
QWidget* m_content = nullptr;
|
||||||
|
bool m_updating = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace diag
|
||||||
82
src/app/SettingsDialog.cpp
Normal file
82
src/app/SettingsDialog.cpp
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
#include "SettingsDialog.h"
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QDoubleSpinBox>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QSpinBox>
|
||||||
|
|
||||||
|
namespace diag {
|
||||||
|
|
||||||
|
void EditorSettings::load()
|
||||||
|
{
|
||||||
|
QSettings s;
|
||||||
|
route.gridStep = s.value(QStringLiteral("grid/step"), 20.0).toDouble();
|
||||||
|
route.snapToGrid = s.value(QStringLiteral("grid/snap"), true).toBool();
|
||||||
|
route.arcOnIntersection = s.value(QStringLiteral("edges/arcOnIntersection"), true).toBool();
|
||||||
|
route.arcRadius = s.value(QStringLiteral("edges/arcRadius"), 6.0).toDouble();
|
||||||
|
scheme = Theme::schemeFromName(
|
||||||
|
s.value(QStringLiteral("view/colorScheme"), QStringLiteral("Light")).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
void EditorSettings::save() const
|
||||||
|
{
|
||||||
|
QSettings s;
|
||||||
|
s.setValue(QStringLiteral("grid/step"), route.gridStep);
|
||||||
|
s.setValue(QStringLiteral("grid/snap"), route.snapToGrid);
|
||||||
|
s.setValue(QStringLiteral("edges/arcOnIntersection"), route.arcOnIntersection);
|
||||||
|
s.setValue(QStringLiteral("edges/arcRadius"), route.arcRadius);
|
||||||
|
s.setValue(QStringLiteral("view/colorScheme"), Theme::schemeName(scheme));
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDialog::SettingsDialog(const EditorSettings& settings, QWidget* parent)
|
||||||
|
: QDialog(parent)
|
||||||
|
{
|
||||||
|
setWindowTitle(tr("Editor settings"));
|
||||||
|
auto* form = new QFormLayout(this);
|
||||||
|
|
||||||
|
m_gridStep = new QSpinBox;
|
||||||
|
m_gridStep->setRange(5, 200);
|
||||||
|
m_gridStep->setValue(int(settings.route.gridStep));
|
||||||
|
form->addRow(tr("Grid step, px"), m_gridStep);
|
||||||
|
|
||||||
|
m_snap = new QCheckBox(tr("Snap nodes to grid"));
|
||||||
|
m_snap->setChecked(settings.route.snapToGrid);
|
||||||
|
form->addRow(QString(), m_snap);
|
||||||
|
|
||||||
|
m_arcs = new QCheckBox(tr("Draw arcs where edges cross"));
|
||||||
|
m_arcs->setChecked(settings.route.arcOnIntersection);
|
||||||
|
form->addRow(QString(), m_arcs);
|
||||||
|
|
||||||
|
m_arcRadius = new QDoubleSpinBox;
|
||||||
|
m_arcRadius->setRange(3.0, 20.0);
|
||||||
|
m_arcRadius->setValue(settings.route.arcRadius);
|
||||||
|
form->addRow(tr("Arc radius, px"), m_arcRadius);
|
||||||
|
|
||||||
|
m_scheme = new QComboBox;
|
||||||
|
for (auto scheme : {ColorScheme::Light, ColorScheme::Dark, ColorScheme::HighContrast})
|
||||||
|
m_scheme->addItem(Theme::schemeName(scheme));
|
||||||
|
m_scheme->setCurrentText(Theme::schemeName(settings.scheme));
|
||||||
|
form->addRow(tr("Color scheme"), m_scheme);
|
||||||
|
|
||||||
|
auto* buttons =
|
||||||
|
new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||||
|
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||||
|
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
form->addRow(buttons);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorSettings SettingsDialog::settings() const
|
||||||
|
{
|
||||||
|
EditorSettings s;
|
||||||
|
s.route.gridStep = m_gridStep->value();
|
||||||
|
s.route.snapToGrid = m_snap->isChecked();
|
||||||
|
s.route.arcOnIntersection = m_arcs->isChecked();
|
||||||
|
s.route.arcRadius = m_arcRadius->value();
|
||||||
|
s.scheme = Theme::schemeFromName(m_scheme->currentText());
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace diag
|
||||||
39
src/app/SettingsDialog.h
Normal file
39
src/app/SettingsDialog.h
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/Router.h"
|
||||||
|
#include "scene/Theme.h"
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
|
||||||
|
class QCheckBox;
|
||||||
|
class QComboBox;
|
||||||
|
class QDoubleSpinBox;
|
||||||
|
class QSpinBox;
|
||||||
|
|
||||||
|
namespace diag {
|
||||||
|
|
||||||
|
struct EditorSettings {
|
||||||
|
RouteConfig route;
|
||||||
|
ColorScheme scheme = ColorScheme::Light;
|
||||||
|
|
||||||
|
void load(); // from QSettings
|
||||||
|
void save() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Editor configuration: grid step, snapping, intersection arcs, color scheme.
|
||||||
|
class SettingsDialog : public QDialog {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit SettingsDialog(const EditorSettings& settings, QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
EditorSettings settings() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QSpinBox* m_gridStep;
|
||||||
|
QCheckBox* m_snap;
|
||||||
|
QCheckBox* m_arcs;
|
||||||
|
QDoubleSpinBox* m_arcRadius;
|
||||||
|
QComboBox* m_scheme;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace diag
|
||||||
28
src/main.cpp
Normal file
28
src/main.cpp
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
#include "app/MainWindow.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
QApplication app(argc, argv);
|
||||||
|
QCoreApplication::setOrganizationName(QStringLiteral("PipeDiagram"));
|
||||||
|
QCoreApplication::setApplicationName(QStringLiteral("PipeDiagram"));
|
||||||
|
|
||||||
|
diag::MainWindow window;
|
||||||
|
window.show();
|
||||||
|
const QStringList args = app.arguments();
|
||||||
|
if (args.contains(QStringLiteral("--sample")))
|
||||||
|
window.loadSample();
|
||||||
|
|
||||||
|
// Headless smoke check: --screenshot <file.png> renders and exits.
|
||||||
|
const int shotIdx = args.indexOf(QStringLiteral("--screenshot"));
|
||||||
|
if (shotIdx >= 0 && shotIdx + 1 < args.size()) {
|
||||||
|
const QString path = args.at(shotIdx + 1);
|
||||||
|
QTimer::singleShot(300, &window, [&window, path] {
|
||||||
|
window.grab().save(path);
|
||||||
|
QApplication::quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return app.exec();
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user