diff --git a/src/plugins/CMakeLists.txt b/src/plugins/CMakeLists.txt index d5f06fd31..e9152c7fc 100644 --- a/src/plugins/CMakeLists.txt +++ b/src/plugins/CMakeLists.txt @@ -107,5 +107,6 @@ add_subdirectory(image_display) add_subdirectory(publisher) add_subdirectory(scene3d) add_subdirectory(topic_echo) +add_subdirectory(topic_viewer) add_subdirectory(world_control) add_subdirectory(world_stats) diff --git a/src/plugins/topic_viewer/CMakeLists.txt b/src/plugins/topic_viewer/CMakeLists.txt new file mode 100644 index 000000000..60318d9db --- /dev/null +++ b/src/plugins/topic_viewer/CMakeLists.txt @@ -0,0 +1,10 @@ +ign_gui_add_plugin(TopicViewer + SOURCES + TopicViewer.cc + QT_HEADERS + TopicViewer.hh + TEST_SOURCES + TopicViewer_TEST.cc + PUBLIC_LINK_LIBS + # ${} +) diff --git a/src/plugins/topic_viewer/TopicViewer.cc b/src/plugins/topic_viewer/TopicViewer.cc new file mode 100644 index 000000000..aae5ff6a0 --- /dev/null +++ b/src/plugins/topic_viewer/TopicViewer.cc @@ -0,0 +1,418 @@ +/* + * Copyright (C) 2020 Open Source Robotics Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +#include +#include +#include + +#include +#include +#include "TopicViewer.hh" + +#define NAME_KEY "name" +#define TYPE_KEY "type" +#define TOPIC_KEY "topic" +#define PATH_KEY "path" +#define PLOT_KEY "plottable" + +#define NAME_ROLE 51 +#define TYPE_ROLE 52 +#define TOPIC_ROLE 53 +#define PATH_ROLE 54 +#define PLOT_ROLE 55 + +namespace ignition +{ +namespace gui +{ +namespace plugins +{ + /// \brief Model for the Topics and their Msgs and Fields + /// a tree model that represents the topics tree with its Msgs + /// Childeren and each msg node has its own fileds/msgs childeren + class TopicsModel : public QStandardItemModel + { + /// \brief roles and names of the model + public: QHash roleNames() const override + { + QHash roles; + roles[NAME_ROLE] = NAME_KEY; + roles[TYPE_ROLE] = TYPE_KEY; + roles[TOPIC_ROLE] = TOPIC_KEY; + roles[PATH_ROLE] = PATH_KEY; + roles[PLOT_ROLE] = PLOT_KEY; + return roles; + } + }; + + class TopicViewerPrivate + { + /// \brief Node for Commincation + public: ignition::transport::Node node; + + /// \brief Model to create it from the available topics and messages + public: TopicsModel *model; + + /// \brief Timer to update the model and keep track of its changes + public: QTimer *timer; + + /// \brief topic: msgType map to keep track of the model current topics + public: std::map currentTopics; + + /// \brief Create the fields model + public: void CreateModel(); + + /// \brief add a topic to the model + /// \param[in] _topic topic name to be displayed + /// \param[in] _msg topic's msg type + public: void AddTopic(const std::string &_topic, + const std::string &_msg); + + /// \brief add a field/msg child to that parent item + /// \param[in] _parentItem a parent for the added field/msg + /// \param[in] _msgName the displayed name of the field/msg + /// \param[in] _msgType field/msg type + public: void AddField(QStandardItem *_parentItem, + const std::string &_msgName, + const std::string &_msgType); + + /// \brief factory method for creating an item + /// \param[in] _name the display name + /// \param[in] _type type of the field of the item + /// \param[in] _path a set of concatenate strings of parent msgs + /// names that lead to that field, starting from the most parent + /// ex : if we have [Collision]msg contains [pose]msg contains [position] + /// msg contains [x,y,z] fields, so the path of x = "pose-position-x" + /// \param[in] _topic the name of the most parent item + /// \return the created Item + public: QStandardItem *FactoryItem(const std::string &_name, + const std::string &_type, + const std::string &_path = "", + const std::string &_topic = ""); + + /// \brief set the topic role name of the item with the most + /// topic parent of that field item + /// \param[in] _item item ref to set its topic + public: void SetItemTopic(QStandardItem *_item); + + /// \brief set the path/ID of the givin item starting from + /// the most topic parent to the field itself + /// \param[in] _item item ref to set its path + public: void SetItemPath(QStandardItem *_item); + + /// \brief get the topic name of selected item + /// \param[in] _item ref to the item to get its parent topic + public: std::string TopicName(const QStandardItem *_item) const; + + /// \brief full path starting from topic name till the msg name + /// \param[in] _index index of the QStanadardItem + /// \return string with all elements separated by '/' + public: std::string ItemPath(const QStandardItem *_item) const; + + /// \brief check if the type is supported in the plotting types + /// \param[in] _type the msg type to check if it is supported + public: bool IsPlotable( + const google::protobuf::FieldDescriptor::Type &_type); + + /// \brief supported types for plotting + public: std::vector plotableTypes; + }; +} +} +} + +using namespace ignition; +using namespace gui; +using namespace plugins; + +TopicViewer::TopicViewer() : Plugin(), dataPtr(new TopicViewerPrivate) +{ + using namespace google::protobuf; + this->dataPtr->plotableTypes.push_back(FieldDescriptor::Type::TYPE_DOUBLE); + this->dataPtr->plotableTypes.push_back(FieldDescriptor::Type::TYPE_FLOAT); + this->dataPtr->plotableTypes.push_back(FieldDescriptor::Type::TYPE_INT32); + this->dataPtr->plotableTypes.push_back(FieldDescriptor::Type::TYPE_INT64); + this->dataPtr->plotableTypes.push_back(FieldDescriptor::Type::TYPE_UINT32); + this->dataPtr->plotableTypes.push_back(FieldDescriptor::Type::TYPE_UINT64); + this->dataPtr->plotableTypes.push_back(FieldDescriptor::Type::TYPE_BOOL); + + this->dataPtr->CreateModel(); + + ignition::gui::App()->Engine()->rootContext()->setContextProperty( + "TopicsModel", this->dataPtr->model); + + this->dataPtr->timer = new QTimer(); + connect(this->dataPtr->timer, SIGNAL(timeout()), this, SLOT(UpdateModel())); + this->dataPtr->timer->start(1000); +} + +////////////////////////////////////////////////// +TopicViewer::~TopicViewer() +{ +} + +////////////////////////////////////////////////// +void TopicViewer::LoadConfig(const tinyxml2::XMLElement *) +{ + if (this->title.empty()) + this->title = "Topic Viewer"; +} + +////////////////////////////////////////////////// +QStandardItemModel *TopicViewer::Model() +{ + return reinterpret_cast(this->dataPtr->model); +} + +////////////////////////////////////////////////// +void TopicViewerPrivate::CreateModel() +{ + this->model = new TopicsModel(); + + std::vector topics; + this->node.TopicList(topics); + + for (unsigned int i = 0; i < topics.size(); ++i) + { + std::vector infoMsgs; + this->node.TopicInfo(topics[i], infoMsgs); + std::string msgType = infoMsgs[0].MsgTypeName(); + this->AddTopic(topics[i], msgType); + } +} + +////////////////////////////////////////////////// +void TopicViewerPrivate::AddTopic(const std::string &_topic, + const std::string &_msg) +{ + QStandardItem *topicItem = this->FactoryItem(_topic, _msg); + topicItem->setWhatsThis("Topic"); + QStandardItem *parent = this->model->invisibleRootItem(); + parent->appendRow(topicItem); + + this->AddField(topicItem , _msg, _msg); + + // store the topics to keep track of them + this->currentTopics[_topic] = _msg; +} + +////////////////////////////////////////////////// +void TopicViewerPrivate::AddField(QStandardItem *_parentItem, + const std::string &_msgName, + const std::string &_msgType) +{ + QStandardItem *msgItem; + + // check if it is a topic, to skip the extra level of the topic Msg + if (_parentItem->whatsThis() == "Topic") + { + msgItem = _parentItem; + // make it different, so next iteration will make a new msg item + msgItem->setWhatsThis("Msg"); + } + else + { + msgItem = this->FactoryItem(_msgName, _msgType); + _parentItem->appendRow(msgItem); + } + + auto msg = ignition::msgs::Factory::New(_msgType); + if (!msg) + { + ignwarn << "Null Msg: " << _msgType << std::endl; + return; + } + + auto msgDescriptor = msg->GetDescriptor(); + if (!msgDescriptor) + { + ignwarn << "Null Descriptor of Msg: " << _msgType << std::endl; + return; + } + + for (int i = 0 ; i < msgDescriptor->field_count(); ++i) + { + auto msgField = msgDescriptor->field(i); + + if (msgField->is_repeated()) + continue; + + auto messageType = msgField->message_type(); + + if (messageType) + this->AddField(msgItem, msgField->name(), messageType->name()); + + else + { + auto msgFieldItem = this->FactoryItem(msgField->name(), + msgField->type_name()); + msgItem->appendRow(msgFieldItem); + + this->SetItemPath(msgFieldItem); + this->SetItemTopic(msgFieldItem); + + // to make the plottable items draggable + if (this->IsPlotable(msgField->type())) + msgFieldItem->setData(QVariant(true), PLOT_ROLE); + } + } +} + +////////////////////////////////////////////////// +QStandardItem *TopicViewerPrivate::FactoryItem(const std::string &_name, + const std::string &_type, + const std::string &_path, + const std::string &_topic) +{ + QString name = QString::fromStdString(_name); + QString type = QString::fromStdString(_type); + QString path = QString::fromStdString(_path); + QString topic = QString::fromStdString(_topic); + + QStandardItem *item = new QStandardItem(name); + + item->setData(QVariant(name), NAME_ROLE); + item->setData(QVariant(type), TYPE_ROLE); + item->setData(QVariant(path), PATH_ROLE); + item->setData(QVariant(topic), TOPIC_ROLE); + item->setData(QVariant(false), PLOT_ROLE); + + return item; +} + +////////////////////////////////////////////////// +void TopicViewerPrivate::SetItemTopic(QStandardItem *_item) +{ + std::string topic = this->TopicName(_item); + QVariant Topic(QString::fromStdString(topic)); + _item->setData(Topic, TOPIC_ROLE); +} + +////////////////////////////////////////////////// +void TopicViewerPrivate::SetItemPath(QStandardItem *_item) +{ + std::string path = this->ItemPath(_item); + QVariant Path(QString::fromStdString(path)); + _item->setData(Path, PATH_ROLE); +} + +////////////////////////////////////////////////// +std::string TopicViewerPrivate::TopicName(const QStandardItem *_item) const +{ + QStandardItem *parent = _item->parent(); + + // get the next parent until you reach the first level parent + while (parent) + { + _item = parent; + parent = parent->parent(); + } + + return _item->data(NAME_ROLE).toString().toStdString(); +} + +////////////////////////////////////////////////// +std::string TopicViewerPrivate::ItemPath(const QStandardItem *_item) const +{ + std::deque path; + while (_item) + { + path.push_front(_item->data(NAME_ROLE).toString().toStdString()); + _item = _item->parent(); + } + + if (path.size()) + path.erase(path.begin()); + + // convert to string + std::string pathString; + + for (unsigned int i = 0; i < path.size()-1; ++i) + pathString += path[i] + "-"; + + if (path.size()) + pathString += path[path.size()-1]; + + return pathString; +} + +///////////////////////////////////////////////// +bool TopicViewerPrivate::IsPlotable( + const google::protobuf::FieldDescriptor::Type &_type) +{ + return std::find(this->plotableTypes.begin(), this->plotableTypes.end(), + _type) != this->plotableTypes.end(); +} + +///////////////////////////////////////////////// +void TopicViewer::UpdateModel() +{ + // get the current topics in the network + std::vector topics; + this->dataPtr->node.TopicList(topics); + + // initialize the topics with the old topics & remove every matched topic + // when you finish advertised topics the remaining topics will be removed + std::map topicsToRemove = + this->dataPtr->currentTopics; + + for (unsigned int i = 0; i < topics.size(); ++i) + { + // get the msg type + std::vector infoMsgs; + this->dataPtr->node.TopicInfo(topics[i], infoMsgs); + std::string msgType = infoMsgs[0].MsgTypeName(); + + // skip the matched topics + if (this->dataPtr->currentTopics.count(topics[i]) && + this->dataPtr->currentTopics[topics[i]] == msgType) + { + topicsToRemove.erase(topics[i]); + continue; + } + + // new topic + this->dataPtr->AddTopic(topics[i], msgType); + } + + // remove the topics that don't exist in the network + for (auto topic : topicsToRemove) + { + auto root = this->dataPtr->model->invisibleRootItem(); + + // search for the topic in the model + for (int i = 0; i < root->rowCount(); ++i) + { + auto child = root->child(i); + + if (child->data(NAME_ROLE).toString().toStdString() == topic.first && + child->data(TYPE_ROLE).toString().toStdString() == topic.second) + { + // remove from model + root->removeRow(i); + // remove from topics as it is a dangling topic + this->dataPtr->currentTopics.erase(topic.first); + break; + } + } + } +} + + +// Register this plugin +IGNITION_ADD_PLUGIN(ignition::gui::plugins::TopicViewer, + ignition::gui::Plugin) diff --git a/src/plugins/topic_viewer/TopicViewer.hh b/src/plugins/topic_viewer/TopicViewer.hh new file mode 100644 index 000000000..0beb03ce7 --- /dev/null +++ b/src/plugins/topic_viewer/TopicViewer.hh @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2020 Open Source Robotics Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace ignition +{ +namespace gui +{ +namespace plugins +{ + class TopicsModel; + class TopicViewerPrivate; + + /// \brief a Plugin to view the topics and their msgs & fields + /// Field's informations can be passed by dragging them via the UI + class TopicViewer : public Plugin + { + Q_OBJECT + + /// \brief Constructor + public: TopicViewer(); + + /// \brief Destructor + public: ~TopicViewer(); + + /// \brief Documentaation inherited + public: void LoadConfig(const tinyxml2::XMLElement *) override; + + /// \brief Get the model of msgs & fields + /// \return Pointer to the model of msgs & fields + public: QStandardItemModel *Model(); + + /// \brief update the model according to the changes of the topics + public slots: void UpdateModel(); + + /// \brief Pointer to private data. + private: std:: unique_ptr dataPtr; + }; + +} +} +} diff --git a/src/plugins/topic_viewer/TopicViewer.qml b/src/plugins/topic_viewer/TopicViewer.qml new file mode 100644 index 000000000..53ccd37ff --- /dev/null +++ b/src/plugins/topic_viewer/TopicViewer.qml @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2020 Open Source Robotics Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ +import QtQml.Models 2.2 +import QtQuick 2.0 +import QtQuick.Controls 1.4 +import QtQuick.Controls 2.2 +import QtQuick.Controls.Styles 1.4 +import QtQuick.Controls.Material 2.1 +import QtQuick.Layouts 1.3 + +TreeView { + objectName: "treeView" + id:tree + model: TopicsModel + + Layout.minimumHeight: 400 + Layout.minimumWidth: 300 + anchors.fill: parent + + property int itemHeight: 30; + + // =========== Colors =========== + property color oddColor: (Material.theme == Material.Light) ? + Material.color(Material.Grey, Material.Shade100): + Material.color(Material.Grey, Material.Shade800); + + property color evenColor: (Material.theme == Material.Light) ? + Material.color(Material.Grey, Material.Shade200): + Material.color(Material.Grey, Material.Shade900); + + property color highlightColor: Material.accentColor; + + + verticalScrollBarPolicy: Qt.ScrollBarAsNeeded + horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff + headerVisible: false + headerDelegate: Rectangle { + visible: false + } + backgroundVisible: false; + TableViewColumn + { + role: "name"; + } + + // =========== Selection =========== + selection: ItemSelectionModel { + model: tree.model + } + selectionMode: SelectionMode.SingleSelection + + // =========== Delegates ============ + rowDelegate: Rectangle + { + id: row + color: (styleData.selected)? highlightColor : + (styleData.row % 2 == 0) ? evenColor : oddColor + height: itemHeight; + } + + itemDelegate: Item { + id: item + + // for fixing the item position + // item pos changes randomly when drag happens (with the copy drag) + anchors.top: parent.top + anchors.right: parent.right + + Drag.mimeData: { "text/plain" : (model === null) ? "" : model.topic + "," + model.path } + + Drag.dragType: Drag.Automatic + Drag.supportedActions : Qt.CopyAction + Drag.active: dragMouse.drag.active + // a point to drag from + Drag.hotSpot.x: 0 + Drag.hotSpot.y: itemHeight + + // used by DropArea that accepts the dragged items + function itemData () + { + return { + "name": model.name, + "type": model.type, + "path": model.path, + "topic": model.topic + } + } + + MouseArea { + id: dragMouse + anchors.fill: parent + + // only plottable items are dragable + drag.target: (model === null) ? null : + (model.plottable) ? parent : null + + // get a copy image of the dragged item + onPressed: parent.grabToImage(function(result) { + parent.Drag.imageSource = result.url + }) + + onReleased: + { + // emit drop event to notify the DropArea (must manually) + parent.Drag.drop(); + } + + hoverEnabled: true + propagateComposedEvents: true + // make the cursor with a drag shape at the plottable items + cursorShape: (model === null) ? Qt.ArrowCursor : (model.plottable) ? + Qt.DragCopyCursor : Qt.ArrowCursor + + onClicked: { + // change the selection of the tree by clearing the prev, select a new one + tree.selection.select(styleData.index,ItemSelectionModel.ClearAndSelect) + + // set the selection index to the index of the clicked item (must set manually) + tree.selection.setCurrentIndex(styleData.index,ItemSelectionModel.ClearAndSelect) + + // the currentIndex of the tree.selection is not the same + // of the tree.currentIndex, so set the tree.currentIndex. + // this is the way to access it as it is read-only + tree.__currentRow = styleData.row + + // set the focus to the selected item to receive the keyboard events + // this is useful to enable navigating with keyboard from the right position + item.forceActiveFocus(); + + tree.expandCollapseMsg(tree.currentIndex); + } + } + + Image { + id: icon + source: "plottable_icon.svg" + height: itemHeight * 0.6 + width: itemHeight * 0.6 + y : itemHeight * 0.2 + visible: (model === null) ? false : model.plottable + } + + Text { + id : field + text: (model === null) ? "" : model.name + color: (Material.theme == Material.Light || styleData.selected) ? + Material.color(Material.Grey, Material.Shade800): + Material.color(Material.Grey, Material.Shade400); + + font.pointSize: 12 + anchors.leftMargin: 5 + anchors.left: icon.right + anchors.right: parent.right + elide: Text.ElideMiddle + y: icon.y + } + + ToolTip { + id: tool_tip + delay: 200 + timeout: 2000 + text: (model === null) ? "Type ?" : "Type: " + model.type; + visible: dragMouse.containsMouse + y: -itemHeight + x: dragMouse.mouseX + enter: null + exit: null + } + } + + property int y_pos: 0 + + style: TreeViewStyle { + branchDelegate: Rectangle { + height: itemHeight + width: itemHeight + color: "transparent" + Image { + id: branchImage + fillMode: Image.Pad + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + sourceSize.height: itemHeight * 0.4 + sourceSize.width: itemHeight * 0.4 + source: styleData.isExpanded ? "minus.png" : "plus.png" + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + onClicked: { + mouse.accepted = true + + // set the current index & current selection and active focus for keyboard + // the reason for that to make the branch selection just like the item selection + // and to fix the animation as it ueses item selection's info + tree.selection.select(styleData.index,ItemSelectionModel.ClearAndSelect) + tree.selection.setCurrentIndex(styleData.index,ItemSelectionModel.ClearAndSelect) + tree.__currentRow = styleData.row + item.forceActiveFocus(); + + expandCollapseMsg(styleData.index); + } + } + } + } + + function expandCollapseMsg(index){ + if (tree.isExpanded(index)) + tree.collapse(index) + else + tree.expand(index); + } + + Transition { + id: expandTransition + NumberAnimation { + property: "y"; + from: (tree.__listView.currentItem) ? tree.__listView.currentItem.y : 0; + duration: 200; + easing.type: Easing.OutQuad + } + } + + Transition { + id: displacedTransition + NumberAnimation { + property: "y"; + duration: 200; + easing.type: Easing.OutQuad; + } + } + + Component.onCompleted: { + tree.__listView.add = expandTransition; + tree.__listView.displaced = displacedTransition; + tree.__listView.removeDisplaced = displacedTransition; + } + +} diff --git a/src/plugins/topic_viewer/TopicViewer.qrc b/src/plugins/topic_viewer/TopicViewer.qrc new file mode 100644 index 000000000..d6ea51680 --- /dev/null +++ b/src/plugins/topic_viewer/TopicViewer.qrc @@ -0,0 +1,8 @@ + + + TopicViewer.qml + plottable_icon.svg + minus.png + plus.png + + diff --git a/src/plugins/topic_viewer/TopicViewer_TEST.cc b/src/plugins/topic_viewer/TopicViewer_TEST.cc new file mode 100644 index 000000000..8ad6c9547 --- /dev/null +++ b/src/plugins/topic_viewer/TopicViewer_TEST.cc @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2020 Open Source Robotics Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ +#include + +#include +#include "test_config.h" // NOLINT(build/include) +#include "ignition/gui/Application.hh" +#include "ignition/gui/Plugin.hh" +#include "ignition/gui/MainWindow.hh" +#include "TopicViewer.hh" + +#define NAME_ROLE 51 +#define TYPE_ROLE 52 +#define TOPIC_ROLE 53 +#define PATH_ROLE 54 +#define PLOT_ROLE 55 + + +int g_argc = 1; +char **g_argv = new char *[g_argc]; + +using namespace ignition; +using namespace gui; +using namespace plugins; + +///////////////////////////////////////////////// +TEST(TopicViewerTest, Load) +{ + common::Console::SetVerbosity(4); + + Application app(g_argc, g_argv); + app.AddPluginPath(std::string(PROJECT_BINARY_PATH) + "/lib"); + + EXPECT_TRUE(app.LoadPlugin("TopicViewer")); + + // Get main window + auto win = app.findChild(); + ASSERT_NE(nullptr, win); + + // Get plugin + auto plugins = win->findChildren(); + EXPECT_EQ(plugins.size(), 1); + + auto plugin = plugins[0]; + EXPECT_EQ(plugin->Title(), "Topic Viewer"); + + // Cleanup + plugins.clear(); +} + +///////////////////////////////////////////////// +TEST(TopicViewerTest, Model) +{ + setenv("IGN_PARTITION", "ign-gazebo-test", 1); + + // =========== Publish ================= + transport::Node node; + + // int + auto pubInt = node.Advertise("/int_topic"); + msgs::Int32 msgInt; + + // collision + auto pub = node.Advertise ("/collision_topic"); + msgs::Collision msg; + + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + pub.Publish(msg); + pubInt.Publish(msgInt); + + // ========== Load the Plugin ============ + common::Console::SetVerbosity(4); + + Application app(g_argc, g_argv); + app.AddPluginPath(std::string(PROJECT_BINARY_PATH) + "/lib"); + + // Load plugin + const char *pluginStr = + "" + "" + "Topic Viewer" + "" + ""; + + tinyxml2::XMLDocument pluginDoc; + EXPECT_EQ(tinyxml2::XML_SUCCESS, pluginDoc.Parse(pluginStr)); + + EXPECT_TRUE(app.LoadPlugin("TopicViewer", + pluginDoc.FirstChildElement("plugin"))); + + // Get main window + auto win = app.findChild(); + EXPECT_NE(nullptr, win); + + // Get plugin + auto plugins = win->findChildren(); + ASSERT_EQ(plugins.size(), 1); + + auto plugin = plugins[0]; + ASSERT_NE(nullptr, plugin); + + // ============= Model ===================== + auto model = plugin->Model(); + ASSERT_NE(model, nullptr); + + auto root = model->invisibleRootItem(); + ASSERT_NE(model, nullptr); + + ASSERT_EQ(root->hasChildren(), true); + + bool foundCollision = false; + bool foundInt = false; + + EXPECT_GE(root->rowCount(), 2); + + // check plotable items + for (int i = 0; i < root->rowCount(); ++i) + { + auto child = root->child(i); + + if (child->data(NAME_ROLE) == "/collision_topic") + { + foundCollision = true; + + EXPECT_EQ(child->data(TYPE_ROLE), "ignition.msgs.Collision"); + EXPECT_EQ(child->rowCount(), 8); + + auto pose = child->child(5); + auto position = pose->child(3); + auto x = position->child(1); + + EXPECT_EQ(x->data(NAME_ROLE), "x"); + EXPECT_EQ(x->data(TYPE_ROLE), "double"); + EXPECT_EQ(x->data(PATH_ROLE), "pose-position-x"); + EXPECT_EQ(x->data(TOPIC_ROLE), "/collision_topic"); + EXPECT_TRUE(x->data(PLOT_ROLE).toBool()); + } + else if (child->data(NAME_ROLE) == "/int_topic") + { + foundInt = true; + + EXPECT_EQ(child->data(TYPE_ROLE), "ignition.msgs.Int32"); + EXPECT_EQ(child->rowCount(), 2); + + auto data = child->child(1); + + EXPECT_EQ(data->data(NAME_ROLE), "data"); + EXPECT_EQ(data->data(TYPE_ROLE), "int32"); + EXPECT_EQ(data->data(PATH_ROLE), "data"); + EXPECT_EQ(data->data(TOPIC_ROLE), "/int_topic"); + EXPECT_TRUE(data->data(PLOT_ROLE).toBool()); + } + else + { + EXPECT_TRUE(false); + } + } + + EXPECT_TRUE(foundCollision); + EXPECT_TRUE(foundInt); + + // =========== Dynamic Adding / Removing ============= + + // Add + auto pubEcho = node.Advertise ("/echo_topic"); + msgs::Collision msgEcho; + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + + pubEcho.Publish(msgEcho); + // Remove + node.Unsubscribe("/int_topic"); + // wait for update timeout + std::this_thread::sleep_for(std::chrono::milliseconds(700)); + + root = plugin->Model()->invisibleRootItem(); + + EXPECT_EQ(root->rowCount(), 2); +} diff --git a/src/plugins/topic_viewer/minus.png b/src/plugins/topic_viewer/minus.png new file mode 100644 index 000000000..14dc6c4dc Binary files /dev/null and b/src/plugins/topic_viewer/minus.png differ diff --git a/src/plugins/topic_viewer/plottable_icon.svg b/src/plugins/topic_viewer/plottable_icon.svg new file mode 100644 index 000000000..bcac9afc2 --- /dev/null +++ b/src/plugins/topic_viewer/plottable_icon.svg @@ -0,0 +1,76 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/src/plugins/topic_viewer/plus.png b/src/plugins/topic_viewer/plus.png new file mode 100644 index 000000000..7d9de0704 Binary files /dev/null and b/src/plugins/topic_viewer/plus.png differ