diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c52422..7d53e2f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,21 +1,14 @@ -cmake_minimum_required(VERSION 3.16) -project(wsapp VERSION 1.0 LANGUAGES CXX) +cmake_minimum_required(VERSION 3.21) +project(WasmWebSocketClient LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) -set(CMAKE_AUTOMOC ON) +set(CMAKE_CXX_STANDARD_REQUIRED ON) -find_package(Qt6 REQUIRED COMPONENTS Core Widgets WebSockets) +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets WebSockets) -add_executable(wsapp - main.cpp - GamesPanel.cpp GamesPanel.h - SettingsTree.cpp SettingsTree.h - VersionsPanel.cpp VersionsPanel.h - WebSocketController.cpp WebSocketController.h -) +qt_standard_project_setup() -target_link_libraries(wsapp PRIVATE - Qt6::Core - Qt6::Widgets - Qt6::WebSockets -) +qt_add_executable(WasmWebSocketClient main.cpp) + +target_link_libraries(WasmWebSocketClient + PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::WebSockets) diff --git a/main.cpp b/main.cpp index 5919865..adc982d 100644 --- a/main.cpp +++ b/main.cpp @@ -1,92 +1,328 @@ #include #include +#include #include #include #include #include #include +#include #include #include #include +#include +#include -#include "GamesPanel.h" -#include "SettingsTree.h" -#include "VersionsPanel.h" -#include "WebSocketController.h" +#include +#include +#include -// Must match tabs->addTab order below -static constexpr int TAB_GAMES = 0; -static constexpr int TAB_VERSIONS = 1; -static constexpr int TAB_MANUAL = 2; -static constexpr int TAB_SETTINGS = 3; -static constexpr int TAB_POWER = 4; -static constexpr int TAB_PANELS = 5; - -static QWidget *makeGamesTab(WebSocketController *ctrl, QWidget *parent) +// ---------------------------------------------------------------- +// Settings tree +// ---------------------------------------------------------------- +class SettingsTree : public QTreeWidget { - auto *panel = new GamesPanel(parent); - ctrl->setGamesPanel(panel); - return panel; -} + Q_OBJECT +public: + // Emitted when user edits a value cell + // key = full slash path, value = new string + Q_SIGNAL void valueEdited(const QString &key, const QString &value); -static QWidget *makeVersionsTab(WebSocketController *ctrl, QWidget *parent) + explicit SettingsTree(QWidget *parent = nullptr) + : QTreeWidget(parent) + { + setColumnCount(2); + setHeaderLabels({"Key", "Value"}); + header()->setSectionResizeMode(0, QHeaderView::Interactive); + header()->setSectionResizeMode(1, QHeaderView::Stretch); + header()->setStretchLastSection(true); + setAlternatingRowColors(true); + setUniformRowHeights(true); + setAnimated(true); + setIndentation(16); + setColumnWidth(0, 240); + + connect(this, &QTreeWidget::itemChanged, + this, &SettingsTree::onItemChanged); + } + + void loadKeys(const QStringList &keys) + { + m_loading = true; + clear(); + m_itemMap.clear(); + + for (const QString &key : keys) { + const QStringList parts = key.split('/'); + QTreeWidgetItem *parent = invisibleRootItem(); + QString accumulated; + + for (int i = 0; i < parts.size(); ++i) { + if (!accumulated.isEmpty()) + accumulated += '/'; + accumulated += parts[i]; + + if (!m_itemMap.contains(accumulated)) { + auto *item = new QTreeWidgetItem(parent); + item->setText(0, parts[i]); + item->setExpanded(false); + + // Only leaf nodes (last part) are editable + const bool isLeaf = (i == parts.size() - 1); + if (isLeaf) { + item->setFlags(item->flags() | Qt::ItemIsEditable); + item->setToolTip(1, "Double-click to edit and send"); + } else { + item->setFlags(item->flags() & ~Qt::ItemIsEditable); + } + + m_itemMap[accumulated] = item; + } + parent = m_itemMap[accumulated]; + } + } + + resizeColumnToContents(0); + m_loading = false; + } + + void setValue(const QString &key, const QString &value) + { + if (!m_itemMap.contains(key)) return; + + m_loading = true; + m_itemMap[key]->setText(1, value); + m_loading = false; + } + +private slots: + void onItemChanged(QTreeWidgetItem *item, int column) + { + if (m_loading || column != 1) return; + + // Find the full path for this item + const QString key = fullPath(item); + if (key.isEmpty()) return; + + emit valueEdited(key, item->text(1)); + } + +private: + QString fullPath(QTreeWidgetItem *item) const + { + // Walk up the tree to reconstruct the full slash-separated path + QStringList parts; + QTreeWidgetItem *cur = item; + while (cur && cur != invisibleRootItem()) { + parts.prepend(cur->text(0)); + cur = cur->parent(); + } + return parts.join('/'); + } + + QMap m_itemMap; + bool m_loading = false; +}; + +// ---------------------------------------------------------------- +// WebSocket controller +// ---------------------------------------------------------------- +class WebSocketController : public QObject { - auto *panel = new VersionsPanel(parent); - ctrl->setVersionsPanel(panel); - return panel; -} + Q_OBJECT +public: + explicit WebSocketController(QLineEdit *urlEdit, + QLabel *statusLabel, + QObject *parent = nullptr) + : QObject(parent), m_urlEdit(urlEdit), m_statusLabel(statusLabel) + {} -static QWidget *makeSettingsTab(WebSocketController *ctrl, QWidget *parent) + QWebSocket *socket() { return &m_socket; } + void addLogView(QTextEdit *l) { m_logs.append(l); } + void setSettingsTree(SettingsTree *t) + { + m_settingsTree = t; + // When user edits a value, send set command then verify + connect(t, &SettingsTree::valueEdited, + this, &WebSocketController::onValueEdited); + } + + bool isConnected() const + { + return m_socket.state() == QAbstractSocket::ConnectedState; + } + +public slots: + void startConnection() + { + const QUrl url(m_urlEdit->text().trimmed()); + if (!url.isValid()) { broadcast("Invalid URL"); return; } + broadcast(QString("Connecting to %1").arg(url.toString())); + m_statusLabel->setText("Connecting..."); + m_socket.open(url); + } + + void closeConnection() + { + broadcast("Closing connection"); + m_settingsKeys.clear(); + m_socket.close(); + } + + void sendCommand(const QString &cmd) + { + if (!isConnected()) { broadcast("[not connected]"); return; } + m_socket.sendTextMessage(cmd); + broadcast(QString("TX: %1").arg(cmd)); + } + + void onConnected() + { + broadcast("Connected"); + m_statusLabel->setText("Connected"); + sendCommand(QStringLiteral("GBL List")); + } + + void onDisconnected() + { + broadcast("Disconnected"); + m_statusLabel->setText("Disconnected"); + m_settingsKeys.clear(); + } + + void onTextMessageReceived(const QString &msg) + { + broadcast(QString("RX: %1").arg(msg)); + handleProtocol(msg); + } + + void onErrorOccurred(QAbstractSocket::SocketError) + { + broadcast(QString("Error: %1").arg(m_socket.errorString())); + m_statusLabel->setText("Error"); + } + + // Called when user finishes editing a cell in the settings tree + void onValueEdited(const QString &key, const QString &newValue) + { + // Send: GBL key=newvalue + sendCommand(QString("GBL %1=%2").arg(key, newValue)); + // Verify: GBL key (machine echoes back confirmed value) + sendCommand(QString("GBL %1").arg(key)); + } + +private: + void handleProtocol(const QString &msg) + { + if (!m_settingsTree) return; + + const QStringList tokens = msg.split(' ', Qt::SkipEmptyParts); + if (tokens.size() < 2) return; + + // GBL List key1 key2 ... + if (tokens[0] == "GBL" && tokens[1] == "List" && tokens.size() > 2) { + m_settingsKeys = tokens.mid(2); + m_settingsTree->loadKeys(m_settingsKeys); + + for (const QString &key : m_settingsKeys) + sendCommand(QString("GBL %1").arg(key)); + + return; + } + + // GBL key=value + if (tokens[0] == "GBL" && tokens.size() >= 2) { + const QString payload = tokens[1]; + const int eqIdx = payload.indexOf('='); + if (eqIdx > 0) { + const QString key = payload.left(eqIdx); + QString value = payload.mid(eqIdx + 1); + if (tokens.size() > 2) + value += ' ' + tokens.mid(2).join(' '); + m_settingsTree->setValue(key, value); + } + } + } + + void broadcast(const QString &line) + { + for (auto *log : m_logs) + log->append(line); + } + + QLineEdit *m_urlEdit = nullptr; + QLabel *m_statusLabel = nullptr; + QWebSocket m_socket; + QList m_logs; + SettingsTree *m_settingsTree = nullptr; + QStringList m_settingsKeys; +}; + +// ---------------------------------------------------------------- +// Tab builders +// ---------------------------------------------------------------- +static QWidget *makeSettingsTab(WebSocketController *controller, QWidget *parent = nullptr) { auto *page = new QWidget(parent); auto *layout = new QVBoxLayout(page); layout->setContentsMargins(2, 2, 2, 2); + auto *tree = new SettingsTree(page); - auto *ph = new QTreeWidgetItem(tree); - ph->setText(0, "Click the settings tab to load..."); + auto *placeholder = new QTreeWidgetItem(tree); + placeholder->setText(0, "Connect to load settings..."); + layout->addWidget(tree, 1); - ctrl->setSettingsTree(tree); + controller->setSettingsTree(tree); return page; } -static QWidget *makeManualTab(WebSocketController *ctrl, QWidget *parent) +static QWidget *makeManualTab(WebSocketController *controller, QWidget *parent = nullptr) { - auto *page = new QWidget(parent); - auto *layout = new QVBoxLayout(page); - auto *row = new QHBoxLayout(); + auto *page = new QWidget(parent); + auto *layout = new QVBoxLayout(page); + + auto *sendRow = new QHBoxLayout(); auto *cmdEdit = new QLineEdit(page); cmdEdit->setPlaceholderText("Enter command to send..."); auto *sendBtn = new QPushButton("Send", page); - row->addWidget(new QLabel("Command:", page)); - row->addWidget(cmdEdit, 1); - row->addWidget(sendBtn); - auto *log = new QTextEdit(page); - log->setReadOnly(true); - layout->addLayout(row); - layout->addWidget(log, 1); - ctrl->addLogView(log); - auto send = [cmdEdit, ctrl]() { + sendRow->addWidget(new QLabel("Command:", page)); + sendRow->addWidget(cmdEdit, 1); + sendRow->addWidget(sendBtn); + + auto *rxLog = new QTextEdit(page); + rxLog->setReadOnly(true); + rxLog->setPlaceholderText("Received messages will appear here..."); + + layout->addLayout(sendRow); + layout->addWidget(rxLog, 1); + controller->addLogView(rxLog); + + auto send = [cmdEdit, controller]() { const QString cmd = cmdEdit->text().trimmed(); - if (!cmd.isEmpty()) { ctrl->sendCommand(cmd); cmdEdit->clear(); } + if (!cmd.isEmpty()) { + controller->sendCommand(cmd); + cmdEdit->clear(); + } }; + QObject::connect(sendBtn, &QPushButton::clicked, page, send); QObject::connect(cmdEdit, &QLineEdit::returnPressed, page, send); return page; } -static QWidget *makePanelsTab(WebSocketController *ctrl, QWidget *parent) +static QWidget *makePanelsTab(WebSocketController *controller, QWidget *parent = nullptr) { auto *page = new QWidget(parent); auto *layout = new QVBoxLayout(page); auto *log = new QTextEdit(page); log->setReadOnly(true); layout->addWidget(log, 1); - ctrl->addLogView(log); + controller->addLogView(log); return page; } -static QWidget *makePlaceholder(const QString &text, QWidget *parent) +static QWidget *makePlaceholder(const QString &text, QWidget *parent = nullptr) { auto *page = new QWidget(parent); auto *layout = new QVBoxLayout(page); @@ -96,73 +332,77 @@ static QWidget *makePlaceholder(const QString &text, QWidget *parent) return page; } +// ---------------------------------------------------------------- +// main +// ---------------------------------------------------------------- int main(int argc, char *argv[]) { QApplication app(argc, argv); QWidget window; - window.setWindowTitle("ESA WebSocket Client"); + window.setWindowTitle("Qt WASM WebSocket Client"); auto *mainLayout = new QVBoxLayout(&window); mainLayout->setContentsMargins(6, 6, 6, 6); mainLayout->setSpacing(4); - auto *headerRow = new QHBoxLayout(); - auto *urlEdit = new QLineEdit(&window); + auto *headerLayout = new QHBoxLayout(); + auto *urlLabel = new QLabel("WebSocket URL:", &window); + auto *urlEdit = new QLineEdit(&window); urlEdit->setPlaceholderText("ws://127.0.0.1:3491/"); urlEdit->setText("ws://127.0.0.1:3491/"); - auto *connectBtn = new QPushButton("Connect", &window); - auto *disconnectBtn = new QPushButton("Disconnect", &window); - disconnectBtn->setEnabled(false); - auto *statusLabel = new QLabel("Disconnected", &window); - headerRow->addWidget(new QLabel("WebSocket URL:", &window)); - headerRow->addWidget(urlEdit, 1); - headerRow->addWidget(connectBtn); - headerRow->addWidget(disconnectBtn); - headerRow->addWidget(statusLabel); - mainLayout->addLayout(headerRow); + auto *connectButton = new QPushButton("Connect", &window); + auto *disconnectButton = new QPushButton("Disconnect", &window); + disconnectButton->setEnabled(false); + auto *statusLabel = new QLabel("Disconnected", &window); - auto *ctrl = new WebSocketController(urlEdit, statusLabel, &window); + headerLayout->addWidget(urlLabel); + headerLayout->addWidget(urlEdit, 1); + headerLayout->addWidget(connectButton); + headerLayout->addWidget(disconnectButton); + headerLayout->addWidget(statusLabel); + mainLayout->addLayout(headerLayout); + + auto *controller = new WebSocketController(urlEdit, statusLabel, &window); auto *tabs = new QTabWidget(&window); - tabs->addTab(makeGamesTab (ctrl, &window), "games"); // 0 - tabs->addTab(makeVersionsTab(ctrl, &window), "versions"); // 1 - tabs->addTab(makeManualTab (ctrl, &window), "manual"); // 2 - tabs->addTab(makeSettingsTab(ctrl, &window), "settings"); // 3 - tabs->addTab(makePlaceholder("Power content here", &window), "power"); // 4 - tabs->addTab(makePanelsTab (ctrl, &window), "panels"); // 5 + tabs->addTab(makePlaceholder("Games content goes here"), "games"); + tabs->addTab(makePlaceholder("Versions content goes here"), "versions"); + tabs->addTab(makeManualTab(controller, &window), "manual"); + tabs->addTab(makeSettingsTab(controller, &window), "settings"); + tabs->addTab(makePlaceholder("Power content goes here"), "power"); + tabs->addTab(makePanelsTab(controller, &window), "panels"); mainLayout->addWidget(tabs, 1); - // Lazy load: each tab fires its own data request on FIRST click only - QObject::connect(tabs, &QTabWidget::currentChanged, &window, - [ctrl](int index) { - switch (index) { - case TAB_GAMES: ctrl->requestGamesData(); break; - case TAB_VERSIONS: ctrl->requestVersionsData(); break; - case TAB_SETTINGS: ctrl->requestSettingsData(); break; - default: break; - } + QObject::connect(connectButton, &QPushButton::clicked, + controller, &WebSocketController::startConnection); + QObject::connect(disconnectButton, &QPushButton::clicked, + controller, &WebSocketController::closeConnection); + + QObject::connect(controller->socket(), &QWebSocket::connected, + controller, &WebSocketController::onConnected); + QObject::connect(controller->socket(), &QWebSocket::disconnected, + controller, &WebSocketController::onDisconnected); + QObject::connect(controller->socket(), &QWebSocket::textMessageReceived, + controller, &WebSocketController::onTextMessageReceived); + QObject::connect(controller->socket(), &QWebSocket::errorOccurred, + controller, &WebSocketController::onErrorOccurred); + + QObject::connect(controller->socket(), &QWebSocket::connected, &window, + [connectButton, disconnectButton]() { + connectButton->setEnabled(false); + disconnectButton->setEnabled(true); + }); + QObject::connect(controller->socket(), &QWebSocket::disconnected, &window, + [connectButton, disconnectButton]() { + connectButton->setEnabled(true); + disconnectButton->setEnabled(false); }); - QObject::connect(connectBtn, &QPushButton::clicked, ctrl, &WebSocketController::startConnection); - QObject::connect(disconnectBtn, &QPushButton::clicked, ctrl, &WebSocketController::closeConnection); - - QObject::connect(ctrl->socket(), &QWebSocket::connected, ctrl, &WebSocketController::onConnected); - QObject::connect(ctrl->socket(), &QWebSocket::disconnected, ctrl, &WebSocketController::onDisconnected); - QObject::connect(ctrl->socket(), &QWebSocket::textMessageReceived, ctrl, &WebSocketController::onTextMessageReceived); - QObject::connect(ctrl->socket(), &QWebSocket::errorOccurred, ctrl, &WebSocketController::onErrorOccurred); - - QObject::connect(ctrl->socket(), &QWebSocket::connected, &window, [connectBtn, disconnectBtn]() { - connectBtn->setEnabled(false); - disconnectBtn->setEnabled(true); - }); - QObject::connect(ctrl->socket(), &QWebSocket::disconnected, &window, [connectBtn, disconnectBtn]() { - connectBtn->setEnabled(true); - disconnectBtn->setEnabled(false); - }); - - window.resize(1050, 620); + window.resize(950, 580); window.show(); return app.exec(); } + +#include "main.moc"