From 72ff342d6325cab42cc3d8401b4fac5b2f7eff3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Mr=C3=A1zek?= Date: Thu, 12 Apr 2018 01:44:51 +0200 Subject: GH-2053 basics of the servers.dat management --- application/CMakeLists.txt | 3 + application/InstancePageProvider.h | 2 + application/InstanceWindow.cpp | 2 +- application/pages/instance/ServersPage.cpp | 743 +++++++++++++++++++++ application/pages/instance/ServersPage.h | 87 +++ application/pages/instance/ServersPage.ui | 207 ++++++ .../resources/multimc/128x128/unknown_server.png | Bin 0 -> 11085 bytes application/resources/multimc/index.theme | 5 + application/resources/multimc/multimc.qrc | 3 + application/widgets/PageContainer.cpp | 15 +- application/widgets/PageContainer.h | 1 + 11 files changed, 1064 insertions(+), 4 deletions(-) create mode 100644 application/pages/instance/ServersPage.cpp create mode 100644 application/pages/instance/ServersPage.h create mode 100644 application/pages/instance/ServersPage.ui create mode 100644 application/resources/multimc/128x128/unknown_server.png diff --git a/application/CMakeLists.txt b/application/CMakeLists.txt index d67540b9..c0268da5 100644 --- a/application/CMakeLists.txt +++ b/application/CMakeLists.txt @@ -100,6 +100,8 @@ SET(MULTIMC_SOURCES pages/instance/ScreenshotsPage.h pages/instance/OtherLogsPage.cpp pages/instance/OtherLogsPage.h + pages/instance/ServersPage.cpp + pages/instance/ServersPage.h pages/instance/LegacyUpgradePage.cpp pages/instance/LegacyUpgradePage.h pages/instance/WorldListPage.cpp @@ -231,6 +233,7 @@ SET(MULTIMC_UIS pages/instance/ScreenshotsPage.ui pages/instance/OtherLogsPage.ui pages/instance/LegacyUpgradePage.ui + pages/instance/ServersPage.ui pages/instance/WorldListPage.ui # Global settings pages diff --git a/application/InstancePageProvider.h b/application/InstancePageProvider.h index 9dda7859..b7a9513c 100644 --- a/application/InstancePageProvider.h +++ b/application/InstancePageProvider.h @@ -15,6 +15,7 @@ #include "pages/instance/OtherLogsPage.h" #include "pages/instance/LegacyUpgradePage.h" #include "pages/instance/WorldListPage.h" +#include "pages/instance/ServersPage.h" class InstancePageProvider : public QObject, public BasePageProvider @@ -43,6 +44,7 @@ public: values.append(new TexturePackPage(onesix.get())); values.append(new NotesPage(onesix.get())); values.append(new WorldListPage(onesix.get(), onesix->worldList(), "worlds", "worlds", tr("Worlds"), "Worlds")); + values.append(new ServersPage(onesix.get())); values.append(new ScreenshotsPage(FS::PathCombine(onesix->minecraftRoot(), "screenshots"))); values.append(new InstanceSettingsPage(onesix.get())); } diff --git a/application/InstanceWindow.cpp b/application/InstanceWindow.cpp index 5895ca3a..b36781a7 100644 --- a/application/InstanceWindow.cpp +++ b/application/InstanceWindow.cpp @@ -181,7 +181,7 @@ void InstanceWindow::closeEvent(QCloseEvent *event) bool InstanceWindow::saveAll() { - return m_container->prepareToClose(); + return m_container->saveAll(); } void InstanceWindow::on_btnKillMinecraft_clicked() 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 +#include +#include +#include +#include +#include +#include +#include + +#include + +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().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 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 *)&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 servers; + auto serversDat = parseServersDat(serversPath()); + if(serversDat) + { + auto &serversList = serversDat->at("servers").as(); + for(auto iter = serversList.begin(); iter != serversList.end(); iter++) + { + auto & serverTag = (*iter).as(); + 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 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 +#include + +#include "pages/BasePage.h" +#include + +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 @@ + + + ServersPage + + + + 0 + 0 + 706 + 575 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + + + 0 + 0 + + + + Tab 1 + + + + + + + + + Ask to download + + + + + Always download + + + + + Never download + + + + + + + + Reso&urces + + + resourceComboBox + + + + + + + + + + &Name + + + nameLine + + + + + + + + + + Address + + + addressLine + + + + + + + + 0 + 0 + + + + true + + + true + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + + 64 + 64 + + + + false + + + false + + + + + + + + + + + &Add + + + + + + + &Remove + + + + + + + Move Up + + + + + + + Move Down + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Refresh + + + + + + + + + + + + + tabWidget + serversView + nameLine + addressLine + resourceComboBox + addBtn + removeBtn + moveUpBtn + moveDownBtn + refreshBtn + + + + diff --git a/application/resources/multimc/128x128/unknown_server.png b/application/resources/multimc/128x128/unknown_server.png new file mode 100644 index 00000000..ec98382d Binary files /dev/null and b/application/resources/multimc/128x128/unknown_server.png differ diff --git a/application/resources/multimc/index.theme b/application/resources/multimc/index.theme index 0fe7e7d7..290f42fb 100644 --- a/application/resources/multimc/index.theme +++ b/application/resources/multimc/index.theme @@ -33,6 +33,11 @@ Size=50 [64x64] Size=64 +[128x128] +Size=128 +MinSize=33 +MaxSize=128 + [128x128/instances] Size=128 MinSize=33 diff --git a/application/resources/multimc/multimc.qrc b/application/resources/multimc/multimc.qrc index bea3a325..55cc601e 100644 --- a/application/resources/multimc/multimc.qrc +++ b/application/resources/multimc/multimc.qrc @@ -239,6 +239,9 @@ 48x48/log.png 64x64/log.png + + 128x128/unknown_server.png + scalable/screenshot-placeholder.svg diff --git a/application/widgets/PageContainer.cpp b/application/widgets/PageContainer.cpp index 98de57e8..c8c2b57a 100644 --- a/application/widgets/PageContainer.cpp +++ b/application/widgets/PageContainer.cpp @@ -218,10 +218,9 @@ void PageContainer::currentChanged(const QModelIndex ¤t) bool PageContainer::prepareToClose() { - for (auto page : m_model->pages()) + if(!saveAll()) { - if (!page->apply()) - return false; + return false; } if (m_currentPage) { @@ -229,3 +228,13 @@ bool PageContainer::prepareToClose() } return true; } + +bool PageContainer::saveAll() +{ + for (auto page : m_model->pages()) + { + if (!page->apply()) + return false; + } + return true; +} diff --git a/application/widgets/PageContainer.h b/application/widgets/PageContainer.h index ea9f8ce1..a05e74c4 100644 --- a/application/widgets/PageContainer.h +++ b/application/widgets/PageContainer.h @@ -46,6 +46,7 @@ public: * @return true if everything can be saved, false if there is something that requires attention */ bool prepareToClose(); + bool saveAll(); /* request close - used by individual pages */ bool requestClose() override -- cgit v1.2.3