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:
Ilya Ashikhmin 2026-07-02 23:36:13 +02:00
parent a52f7bffbf
commit 18289b33e1
10 changed files with 929 additions and 0 deletions

View File

@ -34,3 +34,22 @@ add_library(diagscene STATIC
scene/GridView.cpp
)
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
View 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(&registry, 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(&registry, 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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();
}