diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index eebde9c..a18ce77 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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) diff --git a/src/app/MainWindow.cpp b/src/app/MainWindow.cpp new file mode 100644 index 0000000..bf57344 --- /dev/null +++ b/src/app/MainWindow.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/src/app/MainWindow.h b/src/app/MainWindow.h new file mode 100644 index 0000000..5058ab5 --- /dev/null +++ b/src/app/MainWindow.h @@ -0,0 +1,51 @@ +#pragma once + +#include "SettingsDialog.h" + +#include +#include + +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 diff --git a/src/app/NodePalette.cpp b/src/app/NodePalette.cpp new file mode 100644 index 0000000..c6bbcaf --- /dev/null +++ b/src/app/NodePalette.cpp @@ -0,0 +1,82 @@ +#include "NodePalette.h" + +#include "core/TypeRegistry.h" +#include "scene/GridView.h" + +#include +#include +#include +#include + +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 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 diff --git a/src/app/NodePalette.h b/src/app/NodePalette.h new file mode 100644 index 0000000..75a7a8e --- /dev/null +++ b/src/app/NodePalette.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +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 diff --git a/src/app/PropertyPanel.cpp b/src/app/PropertyPanel.cpp new file mode 100644 index 0000000..3e9094d --- /dev/null +++ b/src/app/PropertyPanel.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(items.first())) { + m_target = Target::Node; + m_id = n->nodeId(); + } else if (auto* e = dynamic_cast(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 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 diff --git a/src/app/PropertyPanel.h b/src/app/PropertyPanel.h new file mode 100644 index 0000000..5863110 --- /dev/null +++ b/src/app/PropertyPanel.h @@ -0,0 +1,42 @@ +#pragma once + +#include "core/Types.h" + +#include +#include + +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 diff --git a/src/app/SettingsDialog.cpp b/src/app/SettingsDialog.cpp new file mode 100644 index 0000000..80c6a98 --- /dev/null +++ b/src/app/SettingsDialog.cpp @@ -0,0 +1,82 @@ +#include "SettingsDialog.h" + +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/src/app/SettingsDialog.h b/src/app/SettingsDialog.h new file mode 100644 index 0000000..7905e05 --- /dev/null +++ b/src/app/SettingsDialog.h @@ -0,0 +1,39 @@ +#pragma once + +#include "core/Router.h" +#include "scene/Theme.h" + +#include + +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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..ce7f293 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,28 @@ +#include "app/MainWindow.h" + +#include +#include + +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 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(); +}