diff --git a/CMakeLists.txt b/CMakeLists.txt index f383700..497e248 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,7 @@ if(WARPPIPE_BUILD_GUI) gui/PresetManager.cpp gui/VolumeWidgets.cpp gui/AudioLevelMeter.cpp + gui/SquareConnectionPainter.cpp ) target_link_libraries(warppipe-gui PRIVATE @@ -102,6 +103,7 @@ if(WARPPIPE_BUILD_GUI) gui/PresetManager.cpp gui/VolumeWidgets.cpp gui/AudioLevelMeter.cpp + gui/SquareConnectionPainter.cpp ) target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING) diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index e05eef2..8dc75df 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -1,12 +1,14 @@ #include "AudioLevelMeter.h" #include "GraphEditorWidget.h" #include "PresetManager.h" +#include "SquareConnectionPainter.h" #include "VolumeWidgets.h" #include "WarpGraphModel.h" #include #include #include +#include #include #include #include @@ -641,6 +643,15 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos, autoArrange->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_L)); QAction *refreshGraph = menu.addAction(QStringLiteral("Refresh Graph")); refreshGraph->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R)); + menu.addSeparator(); + auto *connStyleMenu = menu.addMenu(QStringLiteral("Connection Style")); + auto *styleBezier = connStyleMenu->addAction(QStringLiteral("Bezier Curves")); + styleBezier->setCheckable(true); + styleBezier->setChecked(m_connectionStyle == ConnectionStyleType::kBezier); + auto *styleSquare = connStyleMenu->addAction(QStringLiteral("Square Routing")); + styleSquare->setCheckable(true); + styleSquare->setChecked(m_connectionStyle == ConnectionStyleType::kSquare); + menu.addSeparator(); QAction *saveLayoutAs = menu.addAction(QStringLiteral("Save Layout As...")); QAction *resetLayout = menu.addAction(QStringLiteral("Reset Layout")); @@ -683,6 +694,10 @@ void GraphEditorWidget::showCanvasContextMenu(const QPoint &screenPos, m_model->autoArrange(); m_view->zoomFitAll(); saveLayoutWithViewState(); + } else if (chosen == styleBezier) { + setConnectionStyle(ConnectionStyleType::kBezier); + } else if (chosen == styleSquare) { + setConnectionStyle(ConnectionStyleType::kSquare); } else if (chosen == savePresetAction) { savePreset(); } else if (chosen == loadPresetAction) { @@ -1103,6 +1118,7 @@ void GraphEditorWidget::saveLayoutWithViewState() { QList sizes = m_splitter->sizes(); vs.splitterGraph = sizes.value(0, 1200); vs.splitterSidebar = sizes.value(1, 320); + vs.connectionStyle = static_cast(m_connectionStyle); vs.valid = true; m_model->saveLayout(m_layoutPath, vs); } @@ -1115,6 +1131,9 @@ void GraphEditorWidget::restoreViewState() { if (vs.splitterGraph > 0 || vs.splitterSidebar > 0) { m_splitter->setSizes({vs.splitterGraph, vs.splitterSidebar}); } + if (vs.connectionStyle == static_cast(ConnectionStyleType::kSquare)) { + setConnectionStyle(ConnectionStyleType::kSquare); + } } else { m_view->zoomFitAll(); } @@ -1448,6 +1467,10 @@ void GraphEditorWidget::rebuildRulesList() { " border-radius: 4px; padding: 6px 12px; }" "QPushButton:hover { background: #3a3a44; }" "QPushButton:pressed { background: #44444e; }"); + const QString editBtnStyle = QStringLiteral( + "QPushButton { background: transparent; color: #5070a0; border: none;" + " font-size: 14px; font-weight: bold; padding: 2px 6px; }" + "QPushButton:hover { color: #70a0e0; }"); const QString delBtnStyle = QStringLiteral( "QPushButton { background: transparent; color: #a05050; border: none;" " font-size: 14px; font-weight: bold; padding: 2px 6px; }" @@ -1500,10 +1523,23 @@ void GraphEditorWidget::rebuildRulesList() { cardLayout->addLayout(infoLayout, 1); + auto *editBtn = new QPushButton(QString(QChar(0x270E))); + editBtn->setFixedSize(24, 24); + editBtn->setStyleSheet(editBtnStyle); + warppipe::RuleId ruleId = rule.id; + std::string ruleApp = rule.match.application_name; + std::string ruleBin = rule.match.process_binary; + std::string ruleRole = rule.match.media_role; + std::string ruleTarget = rule.target_node; + connect(editBtn, &QPushButton::clicked, this, + [this, ruleApp, ruleBin, ruleRole, ruleTarget, ruleId]() { + showAddRuleDialog(ruleApp, ruleBin, ruleRole, ruleTarget, ruleId); + }); + cardLayout->addWidget(editBtn); + auto *delBtn = new QPushButton(QString(QChar(0x2715))); delBtn->setFixedSize(24, 24); delBtn->setStyleSheet(delBtnStyle); - warppipe::RuleId ruleId = rule.id; connect(delBtn, &QPushButton::clicked, this, [this, ruleId]() { m_client->RemoveRouteRule(ruleId); rebuildRulesList(); @@ -1523,14 +1559,38 @@ void GraphEditorWidget::rebuildRulesList() { static_cast(layout)->addStretch(); } +void GraphEditorWidget::setConnectionStyle(ConnectionStyleType style) { + if (style == m_connectionStyle) + return; + m_connectionStyle = style; + + if (style == ConnectionStyleType::kSquare) { + m_scene->setConnectionPainter(std::make_unique()); + } else { + m_scene->setConnectionPainter( + std::make_unique()); + } + + for (auto *item : m_scene->items()) { + item->update(); + } + + scheduleSaveLayout(); +} + void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp, const std::string &prefillBin, - const std::string &prefillRole) { + const std::string &prefillRole, + const std::string &prefillTarget, + warppipe::RuleId editRuleId) { if (!m_client) return; + bool editing = editRuleId.value != 0; + QDialog dlg(this); - dlg.setWindowTitle(QStringLiteral("Add Routing Rule")); + dlg.setWindowTitle(editing ? QStringLiteral("Edit Routing Rule") + : QStringLiteral("Add Routing Rule")); dlg.setStyleSheet(QStringLiteral( "QDialog { background: #1e1e22; }" "QLabel { color: #ecf0f6; }" @@ -1575,6 +1635,11 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp, } } } + if (!prefillTarget.empty()) { + int idx = targetCombo->findData(QString::fromStdString(prefillTarget)); + if (idx >= 0) + targetCombo->setCurrentIndex(idx); + } form->addRow(QStringLiteral("Target Node:"), targetCombo); auto *buttons = new QDialogButtonBox( @@ -1606,6 +1671,10 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp, return; } + if (editing) { + m_client->RemoveRouteRule(editRuleId); + } + warppipe::RouteRule rule; rule.match.application_name = appName; rule.match.process_binary = procBin; diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index deec287..c56998f 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -27,6 +27,11 @@ class QTabWidget; class QTimer; class DeleteVirtualNodeCommand; +enum class ConnectionStyleType : uint8_t { + kBezier = 0, + kSquare, +}; + class GraphEditorWidget : public QWidget { Q_OBJECT @@ -76,7 +81,10 @@ private: void rebuildRulesList(); void showAddRuleDialog(const std::string &prefillApp = {}, const std::string &prefillBin = {}, - const std::string &prefillRole = {}); + const std::string &prefillRole = {}, + const std::string &prefillTarget = {}, + warppipe::RuleId editRuleId = {}); + void setConnectionStyle(ConnectionStyleType style); struct PendingPasteLink { std::string outNodeName; @@ -119,4 +127,6 @@ private: QWidget *m_rulesContainer = nullptr; QScrollArea *m_rulesScroll = nullptr; + + ConnectionStyleType m_connectionStyle = ConnectionStyleType::kBezier; }; diff --git a/gui/SquareConnectionPainter.cpp b/gui/SquareConnectionPainter.cpp new file mode 100644 index 0000000..752a7d6 --- /dev/null +++ b/gui/SquareConnectionPainter.cpp @@ -0,0 +1,116 @@ +#include "SquareConnectionPainter.h" + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +QPainterPath SquareConnectionPainter::orthogonalPath( + QtNodes::ConnectionGraphicsObject const &cgo) const { + QPointF out = cgo.endPoint(QtNodes::PortType::Out); + QPointF in = cgo.endPoint(QtNodes::PortType::In); + + constexpr double kRadius = 5.0; + constexpr double kSpacing = 8.0; + constexpr double kMinStub = 20.0; + + auto cId = cgo.connectionId(); + double spread = static_cast(cId.outPortIndex) * kSpacing; + + double dy = in.y() - out.y(); + + if (std::abs(dy) < 0.5 && in.x() > out.x()) { + QPainterPath path(out); + path.lineTo(in); + return path; + } + + double midX = (out.x() + in.x()) / 2.0 + spread; + midX = std::max(midX, out.x() + kMinStub); + if (in.x() > out.x()) + midX = std::min(midX, in.x() - kMinStub); + + double r = std::min({kRadius, std::abs(dy) / 2.0, + std::abs(midX - out.x()), + std::abs(in.x() - midX)}); + r = std::max(r, 0.0); + + double sy = (dy > 0) ? 1.0 : -1.0; + double hDir = (in.x() >= midX) ? 1.0 : -1.0; + + QPainterPath path(out); + + if (r > 0.5) { + path.lineTo(midX - r, out.y()); + path.quadTo(QPointF(midX, out.y()), QPointF(midX, out.y() + sy * r)); + path.lineTo(midX, in.y() - sy * r); + path.quadTo(QPointF(midX, in.y()), QPointF(midX + hDir * r, in.y())); + } else { + path.lineTo(midX, out.y()); + path.lineTo(midX, in.y()); + } + + path.lineTo(in); + return path; +} + +void SquareConnectionPainter::paint( + QPainter *painter, + QtNodes::ConnectionGraphicsObject const &cgo) const { + auto const &style = QtNodes::StyleCollection::connectionStyle(); + + bool const hovered = cgo.connectionState().hovered(); + bool const selected = cgo.isSelected(); + bool const sketch = cgo.connectionState().requiresPort(); + + auto path = orthogonalPath(cgo); + + if (hovered || selected) { + QPen pen; + pen.setWidth(static_cast(2 * style.lineWidth())); + pen.setColor(selected ? style.selectedHaloColor() : style.hoveredColor()); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(path); + } + + if (sketch) { + QPen pen; + pen.setWidth(static_cast(style.constructionLineWidth())); + pen.setColor(style.constructionColor()); + pen.setStyle(Qt::DashLine); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(path); + } else { + QPen pen; + pen.setWidth(style.lineWidth()); + pen.setColor(selected ? style.selectedColor() : style.normalColor()); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(path); + } + + double const pointRadius = style.pointDiameter() / 2.0; + painter->setPen(style.constructionColor()); + painter->setBrush(style.constructionColor()); + painter->drawEllipse(cgo.out(), pointRadius, pointRadius); + painter->drawEllipse(cgo.in(), pointRadius, pointRadius); +} + +QPainterPath SquareConnectionPainter::getPainterStroke( + QtNodes::ConnectionGraphicsObject const &cgo) const { + auto path = orthogonalPath(cgo); + + QPainterPathStroker stroker; + stroker.setWidth(10.0); + + return stroker.createStroke(path); +} diff --git a/gui/SquareConnectionPainter.h b/gui/SquareConnectionPainter.h new file mode 100644 index 0000000..8c6dc44 --- /dev/null +++ b/gui/SquareConnectionPainter.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +class SquareConnectionPainter : public QtNodes::AbstractConnectionPainter { +public: + void paint(QPainter *painter, + QtNodes::ConnectionGraphicsObject const &cgo) const override; + QPainterPath + getPainterStroke(QtNodes::ConnectionGraphicsObject const &cgo) const override; + +private: + QPainterPath + orthogonalPath(QtNodes::ConnectionGraphicsObject const &cgo) const; +}; diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index a62a63a..80d4dcd 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -466,7 +466,8 @@ void WarpGraphModel::refreshFromClient() { if (savedIt != m_savedPositions.end()) { m_positions.emplace(qtId, savedIt->second); } else { - m_positions.emplace(qtId, nextPosition(nodeIt->second)); + QPointF candidate = nextPosition(nodeIt->second); + m_positions.emplace(qtId, findNonOverlappingPosition(candidate, nodeIt->second)); } } @@ -698,6 +699,40 @@ QPointF WarpGraphModel::nextPosition(const WarpNodeData &data) { return pos; } +QPointF WarpGraphModel::findNonOverlappingPosition(QPointF candidate, + const WarpNodeData &data) const { + QSizeF newSize(estimateNodeSize(data)); + constexpr int kMaxAttempts = 50; + + for (int attempt = 0; attempt < kMaxAttempts; ++attempt) { + QRectF newRect(candidate, newSize); + bool overlaps = false; + for (const auto &[existingId, existingPos] : m_positions) { + auto nodeIt = m_nodes.find(existingId); + if (nodeIt == m_nodes.end()) + continue; + QSizeF existingSize; + auto sizeIt = m_sizes.find(existingId); + if (sizeIt != m_sizes.end()) { + existingSize = QSizeF(sizeIt->second); + } else { + existingSize = QSizeF(estimateNodeSize(nodeIt->second)); + } + QRectF existingRect(existingPos, existingSize); + QRectF padded = existingRect.adjusted(-kHorizontalGap / 2, -kVerticalGap / 2, + kHorizontalGap / 2, kVerticalGap / 2); + if (newRect.intersects(padded)) { + candidate.setY(existingRect.bottom() + kVerticalGap); + overlaps = true; + break; + } + } + if (!overlaps) + break; + } + return candidate; +} + bool WarpGraphModel::isGhost(QtNodes::NodeId nodeId) const { return m_ghostNodes.find(nodeId) != m_ghostNodes.end(); } @@ -870,6 +905,7 @@ void WarpGraphModel::saveLayout(const QString &path, viewObj["splitter_graph"] = viewState.splitterGraph; viewObj["splitter_sidebar"] = viewState.splitterSidebar; } + viewObj["connection_style"] = viewState.connectionStyle; root["view"] = viewObj; } @@ -929,6 +965,7 @@ bool WarpGraphModel::loadLayout(const QString &path) { m_savedViewState.centerY = viewObj["center_y"].toDouble(); m_savedViewState.splitterGraph = viewObj["splitter_graph"].toInt(0); m_savedViewState.splitterSidebar = viewObj["splitter_sidebar"].toInt(0); + m_savedViewState.connectionStyle = viewObj["connection_style"].toInt(0); m_savedViewState.valid = true; } diff --git a/gui/WarpGraphModel.h b/gui/WarpGraphModel.h index e667eb5..b339057 100644 --- a/gui/WarpGraphModel.h +++ b/gui/WarpGraphModel.h @@ -88,6 +88,7 @@ public: double centerY; int splitterGraph; int splitterSidebar; + int connectionStyle; bool valid; }; @@ -102,6 +103,7 @@ private: static QString captionForNode(const warppipe::NodeInfo &info); static QVariant styleForNode(WarpNodeType type, bool ghost); QPointF nextPosition(const WarpNodeData &data); + QPointF findNonOverlappingPosition(QPointF candidate, const WarpNodeData &data) const; static QSize estimateNodeSize(const WarpNodeData &data); warppipe::Client *m_client = nullptr;