diff options
author | Petr Mrázek <peterix@gmail.com> | 2018-04-12 01:44:51 +0200 |
---|---|---|
committer | Petr Mrázek <peterix@gmail.com> | 2018-04-23 02:05:22 +0200 |
commit | 72ff342d6325cab42cc3d8401b4fac5b2f7eff3b (patch) | |
tree | c779eaaf41a16be4c9990bec21e661ec6b53bafe /application/pages/instance | |
parent | 6284f070c19053ae2bcf26dd9bccac2e0d35477f (diff) | |
download | MultiMC-72ff342d6325cab42cc3d8401b4fac5b2f7eff3b.tar MultiMC-72ff342d6325cab42cc3d8401b4fac5b2f7eff3b.tar.gz MultiMC-72ff342d6325cab42cc3d8401b4fac5b2f7eff3b.tar.lz MultiMC-72ff342d6325cab42cc3d8401b4fac5b2f7eff3b.tar.xz MultiMC-72ff342d6325cab42cc3d8401b4fac5b2f7eff3b.zip |
GH-2053 basics of the servers.dat management
Diffstat (limited to 'application/pages/instance')
-rw-r--r-- | application/pages/instance/ServersPage.cpp | 743 | ||||
-rw-r--r-- | application/pages/instance/ServersPage.h | 87 | ||||
-rw-r--r-- | application/pages/instance/ServersPage.ui | 207 |
3 files changed, 1037 insertions, 0 deletions
diff --git a/application/pages/instance/ServersPage.cpp b/application/pages/instance/ServersPage.cpp new file mode 100644 index 00000000..f7ddc7da --- /dev/null +++ b/application/pages/instance/ServersPage.cpp @@ -0,0 +1,743 @@ +#include "ServersPage.h" +#include "ui_ServersPage.h" + +#include <FileSystem.h> +#include <sstream> +#include <io/stream_reader.h> +#include <tag_string.h> +#include <tag_primitive.h> +#include <tag_list.h> +#include <tag_compound.h> +#include <minecraft/MinecraftInstance.h> + +#include <QFileSystemWatcher> + +static const int COLUMN_COUNT = 2; // 3 , TBD: latency and other nice things. + +struct Server +{ + // Types + enum class AcceptsTextures : int + { + ASK = 0, + ALWAYS = 1, + NEVER = 2 + }; + + // Methods + Server() + { + m_name = QObject::tr("Minecraft Server"); + } + Server(const QString & name, const QString & address) + { + m_name = name; + m_address = address; + } + Server(nbt::tag_compound& server) + { + std::string addressStr(server["ip"]); + m_address = QString::fromUtf8(addressStr.c_str()); + + std::string nameStr(server["name"]); + m_name = QString::fromUtf8(nameStr.c_str()); + + if(server["icon"]) + { + std::string base64str(server["icon"]); + m_icon = QByteArray::fromBase64(base64str.c_str()); + } + + if(server.has_key("acceptTextures", nbt::tag_type::Byte)) + { + bool value = server["acceptTextures"].as<nbt::tag_byte>().get(); + if(value) + { + m_acceptsTextures = AcceptsTextures::ALWAYS; + } + else + { + m_acceptsTextures = AcceptsTextures::NEVER; + } + } + } + + void serialize(nbt::tag_compound& server) + { + server.insert("name", m_name.toUtf8().toStdString()); + server.insert("ip", m_address.toUtf8().toStdString()); + if(m_icon.size()) + { + server.insert("icon", m_icon.toBase64().toStdString()); + } + if(m_acceptsTextures != AcceptsTextures::ASK) + { + server.insert("acceptTextures", nbt::tag_byte(m_acceptsTextures == AcceptsTextures::ALWAYS)); + } + } + + // Data - persistent and user changeable + QString m_name; + QString m_address; + AcceptsTextures m_acceptsTextures = AcceptsTextures::ASK; + + // Data - persistent and automatically updated + QByteArray m_icon; + + // Data - temporary + bool m_checked = false; + bool m_up = false; + QString m_motd; // https://mctools.org/motd-creator + int m_ping = 0; + int m_currentPlayers = 0; + int m_maxPlayers = 0; +}; + +static std::unique_ptr <nbt::tag_compound> parseServersDat(const QString& filename) +{ + try + { + QByteArray input = FS::read(filename); + std::istringstream foo(std::string(input.constData(), input.size())); + auto pair = nbt::io::read_compound(foo); + + if(pair.first != "") + return nullptr; + + if(pair.second == nullptr) + return nullptr; + + return std::move(pair.second); + } + catch(...) + { + return nullptr; + } +} + +static bool serializeServerDat(const QString& filename, nbt::tag_compound * levelInfo) +{ + try + { + std::ostringstream s; + nbt::io::write_tag("", *levelInfo, s); + QByteArray val(s.str().data(), (int) s.str().size() ); + FS::write(filename, val); + return true; + } + catch(...) + { + return false; + } +} + +class ServersModel: public QAbstractListModel +{ + Q_OBJECT +public: + enum Roles + { + ServerPtrRole = Qt::UserRole, + }; + explicit ServersModel(const QString &path, QObject *parent = 0) + : QAbstractListModel(parent) + { + m_path = path; + m_watcher = new QFileSystemWatcher(this); + connect(m_watcher, &QFileSystemWatcher::fileChanged, this, &ServersModel::fileChanged); + connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, &ServersModel::dirChanged); + m_saveTimer.setSingleShot(true); + m_saveTimer.setInterval(5000); + connect(&m_saveTimer, &QTimer::timeout, this, &ServersModel::save_internal); + } + virtual ~ServersModel() {}; + + void observe() + { + if(m_observed) + { + return; + } + m_observed = true; + + if(!m_loaded) + { + load(); + } + + updateFSObserver(); + } + + void unobserve() + { + if(!m_observed) + { + return; + } + m_observed = false; + + updateFSObserver(); + } + + void lock() + { + if(m_locked) + { + return; + } + saveNow(); + + m_locked = true; + updateFSObserver(); + } + + void unlock() + { + if(!m_locked) + { + return; + } + m_locked = false; + + updateFSObserver(); + } + + int addEmptyRow(int position) + { + if(m_locked) + { + return -1; + } + if(position < 0 || position >= rowCount()) + { + position = rowCount(); + } + beginInsertRows(QModelIndex(), position, position); + m_servers.insert(position, Server()); + endInsertRows(); + scheduleSave(); + return position; + } + + bool removeRow(int row) + { + if(m_locked) + { + return false; + } + if(row < 0 || row >= rowCount()) + { + return false; + } + beginRemoveRows(QModelIndex(), row, row); + m_servers.removeAt(row); + endRemoveRows(); // does absolutely nothing, the selected server stays as the next line... + scheduleSave(); + return true; + } + + bool moveUp(int row) + { + if(m_locked) + { + return false; + } + if(row <= 0) + { + return false; + } + beginMoveRows(QModelIndex(), row, row, QModelIndex(), row - 1); + m_servers.swap(row-1, row); + endMoveRows(); + scheduleSave(); + return true; + } + + bool moveDown(int row) + { + if(m_locked) + { + return false; + } + int count = rowCount(); + if(row + 1 >= count) + { + return false; + } + beginMoveRows(QModelIndex(), row, row, QModelIndex(), row + 2); + m_servers.swap(row+1, row); + endMoveRows(); + scheduleSave(); + return true; + } + + QVariant headerData(int section, Qt::Orientation orientation, int role) const override + { + if (section < 0 || section >= COLUMN_COUNT) + return QVariant(); + + if(role == Qt::DisplayRole) + { + switch(section) + { + case 0: + return tr("Name"); + case 1: + return tr("Address"); + case 2: + return tr("Latency"); + } + } + + return QAbstractListModel::headerData(section, orientation, role); + } + + virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override + { + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + int column = index.column(); + if(column < 0 || column >= COLUMN_COUNT) + return QVariant(); + + if (row < 0 || row >= m_servers.size()) + return QVariant(); + + switch(column) + { + case 0: + switch (role) + { + case Qt::DecorationRole: + { + auto & bytes = m_servers[row].m_icon; + if(bytes.size()) + { + QPixmap px; + if(px.loadFromData(bytes)) + return QIcon(px); + } + return MMC->getThemedIcon("unknown_server"); + } + case Qt::DisplayRole: + return m_servers[row].m_name; + case ServerPtrRole: + return QVariant::fromValue<void *>((void *)&m_servers[row]); + default: + return QVariant(); + } + case 1: + switch (role) + { + case Qt::DisplayRole: + return m_servers[row].m_address; + default: + return QVariant(); + } + case 2: + switch (role) + { + case Qt::DisplayRole: + return m_servers[row].m_ping; + default: + return QVariant(); + } + default: + return QVariant(); + } + } + + virtual int rowCount(const QModelIndex &parent = QModelIndex()) const override + { + return m_servers.size(); + } + int columnCount(const QModelIndex & parent) const override + { + return COLUMN_COUNT; + } + + Server * at(int index) + { + if(index < 0 || index >= rowCount()) + { + return nullptr; + } + return &m_servers[index]; + } + + void setName(int row, const QString & name) + { + if(m_locked) + { + return; + } + auto server = at(row); + if(!server || server->m_name == name) + { + return; + } + server->m_name = name; + emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1)); + scheduleSave(); + } + + void setAddress(int row, const QString & address) + { + if(m_locked) + { + return; + } + auto server = at(row); + if(!server || server->m_address == address) + { + return; + } + server->m_address = address; + emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1)); + scheduleSave(); + } + + void setAcceptsTextures(int row, Server::AcceptsTextures textures) + { + if(m_locked) + { + return; + } + auto server = at(row); + if(!server || server->m_acceptsTextures == textures) + { + return; + } + server->m_acceptsTextures = textures; + emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1)); + scheduleSave(); + } + + void load() + { + cancelSave(); + beginResetModel(); + QList<Server> servers; + auto serversDat = parseServersDat(serversPath()); + if(serversDat) + { + auto &serversList = serversDat->at("servers").as<nbt::tag_list>(); + for(auto iter = serversList.begin(); iter != serversList.end(); iter++) + { + auto & serverTag = (*iter).as<nbt::tag_compound>(); + Server s(serverTag); + servers.append(s); + } + } + m_servers.swap(servers); + m_loaded = true; + endResetModel(); + } + + void saveNow() + { + if(saveIsScheduled()) + { + save_internal(); + } + } + + +public slots: + void dirChanged(const QString& path) + { + qDebug() << "Changed:" << path; + load(); + } + void fileChanged(const QString& path) + { + qDebug() << "Changed:" << path; + } + +private slots: + void save_internal() + { + cancelSave(); + qDebug() << "Server list save is performed for" << m_path; + + nbt::tag_compound out; + nbt::tag_list list; + for(auto & server: m_servers) + { + nbt::tag_compound serverNbt; + server.serialize(serverNbt); + list.push_back(std::move(serverNbt)); + } + out.insert("servers", nbt::value(std::move(list))); + + if(!serializeServerDat(serversPath(), &out)) + { + qDebug() << "Failed to save server list:" << m_path << "Will try again."; + scheduleSave(); + } + } + +private: + void scheduleSave() + { + if(!m_loaded) + { + qDebug() << "Server list should never save if it didn't successfully load, path:" << m_path; + return; + } + if(!m_dirty) + { + m_dirty = true; + qDebug() << "Server list save is scheduled for" << m_path; + } + m_saveTimer.start(); + } + + void cancelSave() + { + m_dirty = false; + m_saveTimer.stop(); + } + + bool saveIsScheduled() const + { + return m_dirty; + } + + void updateFSObserver() + { + bool observingFS = m_watcher->directories().contains(m_path); + if(m_observed && m_locked) + { + if(!observingFS) + { + qWarning() << "Will watch" << m_path; + if(!m_watcher->addPath(m_path)) + { + qWarning() << "Failed to start watching" << m_path; + } + } + } + else + { + if(observingFS) + { + qWarning() << "Will stop watching" << m_path; + if(!m_watcher->removePath(m_path)) + { + qWarning() << "Failed to stop watching" << m_path; + } + } + } + } + + QString serversPath() + { + QFileInfo foo(FS::PathCombine(m_path, "servers.dat")); + return foo.canonicalFilePath(); + } + +private: + bool m_loaded = false; + bool m_locked = false; + bool m_observed = false; + bool m_dirty = false; + QString m_path; + QList<Server> m_servers; + QFileSystemWatcher *m_watcher = nullptr; + QTimer m_saveTimer; +}; + +ServersPage::ServersPage(MinecraftInstance * inst, QWidget* parent) + : QWidget(parent), ui(new Ui::ServersPage) +{ + ui->setupUi(this); + ui->tabWidget->tabBar()->hide(); + m_inst = inst; + m_model = new ServersModel(inst->minecraftRoot(), this); + ui->serversView->setIconSize(QSize(64,64)); + ui->serversView->setModel(m_model); + auto head = ui->serversView->header(); + if(head->count()) + { + head->setSectionResizeMode(0, QHeaderView::Stretch); + for(int i = 1; i < head->count(); i++) + { + head->setSectionResizeMode(i, QHeaderView::ResizeToContents); + } + } + + auto selectionModel = ui->serversView->selectionModel(); + connect(selectionModel, &QItemSelectionModel::currentChanged, this, &ServersPage::currentChanged); + connect(m_inst, &MinecraftInstance::runningStatusChanged, this, &ServersPage::on_RunningState_changed); + connect(ui->nameLine, &QLineEdit::textEdited, this, &ServersPage::nameEdited); + connect(ui->addressLine, &QLineEdit::textEdited, this, &ServersPage::addressEdited); + connect(ui->resourceComboBox, SIGNAL(currentIndexChanged(int)), this, SLOT(resourceIndexChanged(int))); + connect(m_model, &QAbstractItemModel::rowsRemoved, this, &ServersPage::rowsRemoved); + + m_locked = m_inst->isRunning(); + if(m_locked) + { + m_model->lock(); + } + + updateState(); +} + +ServersPage::~ServersPage() +{ + m_model->saveNow(); +} + +void ServersPage::on_RunningState_changed(bool running) +{ + if(m_locked == running) + { + return; + } + m_locked = running; + if(m_locked) + { + m_model->lock(); + } + else + { + m_model->unlock(); + } + updateState(); +} + +void ServersPage::currentChanged(const QModelIndex ¤t, const QModelIndex &previous) +{ + int nextServer = -1; + if (!current.isValid()) + { + nextServer = -1; + } + else + { + nextServer = current.row(); + } + currentServer = nextServer; + updateState(); +} + +// WARNING: this is here because currentChanged is not accurate when removing rows. the current item needs to be fixed up after removal. +void ServersPage::rowsRemoved(const QModelIndex& parent, int first, int last) +{ + if(currentServer < first) + { + // current was before the removal + return; + } + else if(currentServer >= first && currentServer <= last) + { + // current got removed... + return; + } + else + { + // current was past the removal + int count = last - first + 1; + currentServer -= count; + } +} + +void ServersPage::nameEdited(const QString& name) +{ + m_model->setName(currentServer, name); +} + +void ServersPage::addressEdited(const QString& address) +{ + m_model->setAddress(currentServer, address); +} + +void ServersPage::resourceIndexChanged(int index) +{ + auto acceptsTextures = Server::AcceptsTextures(index); + m_model->setAcceptsTextures(currentServer, acceptsTextures); +} + +void ServersPage::updateState() +{ + auto server = m_model->at(currentServer); + + bool serverEditEnabled = server && !m_locked; + ui->addressLine->setEnabled(serverEditEnabled); + ui->nameLine->setEnabled(serverEditEnabled); + ui->resourceComboBox->setEnabled(serverEditEnabled); + ui->moveDownBtn->setEnabled(serverEditEnabled); + ui->moveUpBtn->setEnabled(serverEditEnabled); + ui->removeBtn->setEnabled(serverEditEnabled); + + if(server) + { + ui->addressLine->setText(server->m_address); + ui->nameLine->setText(server->m_name); + ui->resourceComboBox->setCurrentIndex(int(server->m_acceptsTextures)); + } + else + { + ui->addressLine->setText(QString()); + ui->nameLine->setText(QString()); + ui->resourceComboBox->setCurrentIndex(0); + } + + ui->addBtn->setDisabled(m_locked); +} + +void ServersPage::openedImpl() +{ + m_model->observe(); +} + +void ServersPage::closedImpl() +{ + m_model->unobserve(); +} + +void ServersPage::on_addBtn_clicked() +{ + int position = m_model->addEmptyRow(currentServer + 1); + if(position < 0) + { + return; + } + // select the new row + ui->serversView->selectionModel()->setCurrentIndex( + m_model->index(position), + QItemSelectionModel::SelectCurrent | QItemSelectionModel::Clear | QItemSelectionModel::Rows + ); + currentServer = position; +} + +void ServersPage::on_removeBtn_clicked() +{ + m_model->removeRow(currentServer); +} + +void ServersPage::on_moveUpBtn_clicked() +{ + if(m_model->moveUp(currentServer)) + { + currentServer --; + } +} + +void ServersPage::on_moveDownBtn_clicked() +{ + if(m_model->moveDown(currentServer)) + { + currentServer ++; + } +} + +void ServersPage::on_refreshBtn_clicked() +{ + m_model->load(); +} + +#include "ServersPage.moc" diff --git a/application/pages/instance/ServersPage.h b/application/pages/instance/ServersPage.h new file mode 100644 index 00000000..6c812bd9 --- /dev/null +++ b/application/pages/instance/ServersPage.h @@ -0,0 +1,87 @@ +/* Copyright 2013-2018 MultiMC Contributors + * + * 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. + */ + +#pragma once + +#include <QWidget> +#include <QString> + +#include "pages/BasePage.h" +#include <MultiMC.h> + +namespace Ui +{ +class ServersPage; +} + +struct Server; +class ServersModel; +class MinecraftInstance; + +class ServersPage : public QWidget, public BasePage +{ + Q_OBJECT + +public: + explicit ServersPage(MinecraftInstance *inst, QWidget *parent = 0); + virtual ~ServersPage(); + + void openedImpl() override; + void closedImpl() override; + + virtual QString displayName() const override + { + return tr("Servers"); + } + virtual QIcon icon() const override + { + return MMC->getThemedIcon("unknown_server"); + } + virtual QString id() const override + { + return "servers"; + } + virtual QString helpPage() const override + { + return "Servers-management"; + } +private: + void updateState(); + void scheduleSave(); + bool saveIsScheduled() const; + +private slots: + void currentChanged(const QModelIndex ¤t, const QModelIndex &previous); + void rowsRemoved(const QModelIndex &parent, int first, int last); + + void on_addBtn_clicked(); + void on_removeBtn_clicked(); + void on_moveUpBtn_clicked(); + void on_moveDownBtn_clicked(); + void on_refreshBtn_clicked(); + void on_RunningState_changed(bool running); + + void nameEdited(const QString & name); + void addressEdited(const QString & address); + void resourceIndexChanged(int index); + +private: // data + int currentServer = -1; + bool m_locked = true; + Ui::ServersPage *ui = nullptr; + ServersModel * m_model = nullptr; + MinecraftInstance * m_inst = nullptr; +}; + diff --git a/application/pages/instance/ServersPage.ui b/application/pages/instance/ServersPage.ui new file mode 100644 index 00000000..6d1a9bc0 --- /dev/null +++ b/application/pages/instance/ServersPage.ui @@ -0,0 +1,207 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>ServersPage</class> + <widget class="QWidget" name="ServersPage"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>706</width> + <height>575</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <widget class="QTabWidget" name="tabWidget"> + <property name="currentIndex"> + <number>0</number> + </property> + <widget class="QWidget" name="tab"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <attribute name="title"> + <string notr="true">Tab 1</string> + </attribute> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <layout class="QGridLayout" name="gridLayout"> + <item row="3" column="1"> + <widget class="QComboBox" name="resourceComboBox"> + <item> + <property name="text"> + <string>Ask to download</string> + </property> + </item> + <item> + <property name="text"> + <string>Always download</string> + </property> + </item> + <item> + <property name="text"> + <string>Never download</string> + </property> + </item> + </widget> + </item> + <item row="3" column="0"> + <widget class="QLabel" name="resourcesLabel"> + <property name="text"> + <string>Reso&urces</string> + </property> + <property name="buddy"> + <cstring>resourceComboBox</cstring> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QLineEdit" name="addressLine"/> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="nameLabel"> + <property name="text"> + <string>&Name</string> + </property> + <property name="buddy"> + <cstring>nameLine</cstring> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QLineEdit" name="nameLine"/> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="addressLabel"> + <property name="text"> + <string>Address</string> + </property> + <property name="buddy"> + <cstring>addressLine</cstring> + </property> + </widget> + </item> + <item row="0" column="0" colspan="2"> + <widget class="QTreeView" name="serversView"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="acceptDrops"> + <bool>true</bool> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::SingleSelection</enum> + </property> + <property name="selectionBehavior"> + <enum>QAbstractItemView::SelectRows</enum> + </property> + <property name="iconSize"> + <size> + <width>64</width> + <height>64</height> + </size> + </property> + <property name="rootIsDecorated"> + <bool>false</bool> + </property> + <attribute name="headerStretchLastSection"> + <bool>false</bool> + </attribute> + </widget> + </item> + </layout> + </item> + <item> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <widget class="QPushButton" name="addBtn"> + <property name="text"> + <string>&Add</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="removeBtn"> + <property name="text"> + <string>&Remove</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="moveUpBtn"> + <property name="text"> + <string>Move Up</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="moveDownBtn"> + <property name="text"> + <string>Move Down</string> + </property> + </widget> + </item> + <item> + <spacer name="verticalSpacer"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>20</width> + <height>40</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="refreshBtn"> + <property name="text"> + <string>Refresh</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + </widget> + </item> + </layout> + </widget> + <tabstops> + <tabstop>tabWidget</tabstop> + <tabstop>serversView</tabstop> + <tabstop>nameLine</tabstop> + <tabstop>addressLine</tabstop> + <tabstop>resourceComboBox</tabstop> + <tabstop>addBtn</tabstop> + <tabstop>removeBtn</tabstop> + <tabstop>moveUpBtn</tabstop> + <tabstop>moveDownBtn</tabstop> + <tabstop>refreshBtn</tabstop> + </tabstops> + <resources/> + <connections/> +</ui> |