diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d2d8ed542..8fc3aed6a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -424,12 +424,14 @@ set(mmapper_SRCS mapstorage/jsonmapstorage.h mapstorage/mapstorage.cpp mapstorage/mapstorage.h - mpi/mpifilter.cpp - mpi/mpifilter.h mpi/remoteedit.cpp mpi/remoteedit.h mpi/remoteeditsession.cpp mpi/remoteeditsession.h + mpi/RemoteEditDraftStore.cpp + mpi/RemoteEditDraftStore.h + mpi/RemoteEditPanel.cpp + mpi/RemoteEditPanel.h mpi/RemoteEditDocumentOps.cpp mpi/RemoteEditDocumentOps.h mpi/RemoteEditHighlighter.cpp diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 4ebe6032d..4cb60ec30 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -67,6 +67,12 @@ NODISCARD const char *getPlatformEditor() ConstString SETTINGS_ORGANIZATION = "MUME"; ConstString OLD_SETTINGS_ORGANIZATION = "Caligor soft"; ConstString SETTINGS_APPLICATION = "MMapper2"; + +std::unique_ptr makeAppSettings() +{ + return std::make_unique(SETTINGS_ORGANIZATION, SETTINGS_APPLICATION); +} + ConstString SETTINGS_FIRST_TIME_KEY = "General/Run first time"; class NODISCARD Settings final @@ -301,6 +307,7 @@ ConstString KEY_TAB_COMPLETION_DICTIONARY_SIZE = "Tab completion dictionary size ConstString KEY_THEME = "Theme"; ConstString KEY_TLS_ENCRYPTION = "TLS encryption"; ConstString KEY_USE_INTERNAL_EDITOR = "Use internal editor"; +ConstString KEY_EDITOR_DIRECTORY = "Editor directory"; ConstString KEY_USE_TRILINEAR_FILTERING = "Use trilinear filtering"; ConstString KEY_WEATHER_ATMOSPHERE_INTENSITY = "weather.atmosphereIntensity"; ConstString KEY_WEATHER_PRECIPITATION_INTENSITY = "weather.precipitationIntensity"; @@ -599,6 +606,7 @@ void Configuration::reset() ConstString DEFAULT_MMAPPER_SUBDIR = "/MMapper"; ConstString DEFAULT_LOGS_SUBDIR = "/Logs"; +ConstString DEFAULT_EDITOR_SUBDIR = "/Editor"; ConstString DEFAULT_RESOURCES_SUBDIR = "/Resources"; NODISCARD static QString getDefaultDirectory() @@ -770,6 +778,11 @@ void Configuration::MumeClientProtocolSettings::read(const QSettings &conf) internalRemoteEditor = conf.value(KEY_USE_INTERNAL_EDITOR, true).toBool(); externalRemoteEditorCommand = conf.value(KEY_EXTERNAL_EDITOR_COMMAND, getPlatformEditor()) .toString(); + editorDirectory = conf.value(KEY_EDITOR_DIRECTORY, + getDefaultDirectory() + .append(DEFAULT_MMAPPER_SUBDIR) + .append(DEFAULT_EDITOR_SUBDIR)) + .toString(); } void Configuration::MumeNativeSettings::read(const QSettings &conf) @@ -968,6 +981,7 @@ void Configuration::MumeClientProtocolSettings::write(QSettings &conf) const { conf.setValue(KEY_USE_INTERNAL_EDITOR, internalRemoteEditor); conf.setValue(KEY_EXTERNAL_EDITOR_COMMAND, externalRemoteEditorCommand); + conf.setValue(KEY_EDITOR_DIRECTORY, editorDirectory); } void Configuration::PathMachineSettings::write(QSettings &conf) const diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index e2fa18c8a..40b28ec6a 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -125,6 +126,7 @@ class NODISCARD Configuration final { bool internalRemoteEditor = false; QString externalRemoteEditorCommand; + QString editorDirectory; private: SUBGROUP(); @@ -490,6 +492,9 @@ void setEnteredMain(); /// Returns a reference to the application configuration object. NODISCARD Configuration &setConfig(); NODISCARD const Configuration &getConfig(); +/// A QSettings in the same organization/application scope as the +/// configuration itself, for subsystems that persist their own small state. +NODISCARD std::unique_ptr makeAppSettings(); using SharedCanvasNamedColorOptions = std::shared_ptr; diff --git a/src/global/AsyncTasks.cpp b/src/global/AsyncTasks.cpp index 9dc043ddf..86950da9c 100644 --- a/src/global/AsyncTasks.cpp +++ b/src/global/AsyncTasks.cpp @@ -37,6 +37,8 @@ std::string get_type_name(const AsyncTaskTypeEnum type) return "IO Task"; case Task: return "Task"; + case RemoteEdit: + return "RemoteEdit"; } return "(error)"; } diff --git a/src/global/AsyncTasks.h b/src/global/AsyncTasks.h index 8a2b955e2..165475a69 100644 --- a/src/global/AsyncTasks.h +++ b/src/global/AsyncTasks.h @@ -10,7 +10,7 @@ class AnsiOstream; -enum class NODISCARD AsyncTaskTypeEnum : uint8_t { IO, Task }; +enum class NODISCARD AsyncTaskTypeEnum : uint8_t { IO, Task, RemoteEdit }; namespace async_tasks { diff --git a/src/global/random.cpp b/src/global/random.cpp index d1b9af923..96d34472a 100644 --- a/src/global/random.cpp +++ b/src/global/random.cpp @@ -5,6 +5,7 @@ #include "random.h" #include +#include NODISCARD static auto expensiveSeed() { @@ -26,3 +27,15 @@ size_t getRandom(const size_t max) std::uniform_int_distribution dist{0u, max}; // inclusive return dist(RandomEngine::getSingleton()); } + +std::string getRandomString(const size_t length) +{ + static constexpr const std::string_view chars + = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + std::string result; + result.reserve(length); + for (size_t i = 0; i < length; ++i) { + result += chars[getRandom(chars.size() - 1u)]; + } + return result; +} diff --git a/src/global/random.h b/src/global/random.h index ad8d6016c..a30479b91 100644 --- a/src/global/random.h +++ b/src/global/random.h @@ -24,6 +24,8 @@ class NODISCARD RandomEngine final : public std::mt19937 // returns a uniformly-distributed random number in [0..max], inclusive NODISCARD extern size_t getRandom(size_t max); +// returns `length` random ASCII alphanumerics +NODISCARD extern std::string getRandomString(size_t length); template NODISCARD decltype(auto) chooseRandomElement(T &&container) diff --git a/src/mainwindow/TasksPanel.cpp b/src/mainwindow/TasksPanel.cpp index 7c21bc734..099caa78f 100644 --- a/src/mainwindow/TasksPanel.cpp +++ b/src/mainwindow/TasksPanel.cpp @@ -9,6 +9,9 @@ #include "../global/SendToUser.h" #include "../global/thread_utils.h" #include "../global/window_utils.h" +#include "../mpi/remoteedit.h" +#include "../proxy/connectionlistener.h" +#include "../proxy/proxy.h" #include "AsyncTypes.h" #include "mainwindow.h" @@ -73,15 +76,18 @@ struct NODISCARD TasksPanel::ListItem final : public QWidget return QScopedPointer{progress.release()}; }); QScopedPointer m_cancelButton; + QScopedPointer m_actionButton; public: - explicit ListItem(async_tasks::AsyncTaskHandle moved_task) + explicit ListItem(async_tasks::AsyncTaskHandle moved_task, MainWindow &mainWindow) : m_task{std::move(moved_task)} , m_cancelButton{mmqt::makeQScopedPointer(m_task)} + , m_actionButton{mmqt::makeQScopedPointer()} { const auto layout = mmqt::makeQPointer(this); layout->addWidget(m_label.get()); layout->addWidget(m_progress.get()); + layout->addWidget(m_cancelButton.get()); layout->insertStretch(-1); // must be after all the addWidget() calls updateProgress(); @@ -374,7 +380,7 @@ void TasksPanel::add_new_item(const TaskHandle &handle) { const auto &task = handle.task; // NOLINTNEXTLINE (no, this is not leaked; Qt manages it) - if (auto *const item = new ListItem(task)) { + if (auto *const item = new ListItem(task, m_mainWindow)) { getLayout().addWidget(item); getKnownTasks().emplace(task.getId(), task); } diff --git a/src/mainwindow/mainwindow-compact.cpp b/src/mainwindow/mainwindow-compact.cpp index c1c0262fa..109508348 100644 --- a/src/mainwindow/mainwindow-compact.cpp +++ b/src/mainwindow/mainwindow-compact.cpp @@ -105,14 +105,15 @@ void MainWindow::buildDefaultCompactLayout() // One tabbed group so a single panel shows at a time, below the // map; hidden docks stay hidden and appear as tabs when shown. - const std::array docks{m_dockDialogClient, + const std::array docks{m_dockDialogClient, m_dockDialogGroup, m_dockDialogRoom, m_dockDialogDescription, m_dockDialogLog, m_dockDialogAdventure, m_dockDialogTimers, - m_dockDialogAsync}; + m_dockDialogAsync, + m_dockDialogRemoteEdits}; QDockWidget *first = nullptr; for (QDockWidget *const dock : docks) { dock->setFloating(false); diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 8647333d4..bce40696a 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -28,6 +28,8 @@ #include "../media/AudioManager.h" #include "../media/DescriptionWidget.h" #include "../media/MediaLibrary.h" +#include "../mpi/RemoteEditPanel.h" +#include "../mpi/remoteedit.h" #include "../pathmachine/mmapper2pathmachine.h" #include "../preferences/configdialog.h" #include "../proxy/connectionlistener.h" @@ -111,6 +113,7 @@ static void addApplicationFont() MainWindow::~MainWindow() { g_mainWindow = nullptr; + remote_edit::setInstance(nullptr); mmqt::rdisconnect(this); async_tasks::cleanup(); delete m_listener; @@ -139,6 +142,9 @@ MainWindow::MainWindow() m_prespammedPath = new PrespammedPath(this); + m_remoteEdit = new RemoteEdit(this); + remote_edit::setInstance(m_remoteEdit); + m_groupManager = new Mmapper2Group(this); m_groupManager->setObjectName("GroupManager"); @@ -305,6 +311,27 @@ MainWindow::MainWindow() m_dockDialogAsync = dock; }); + // Remote edits (MPI editor/viewer tabs) + std::invoke([this] { + auto *const w = new RemoteEditPanel{deref(m_remoteEdit), this}; + auto *const dock = new QDockWidget(tr("Remote Edits Panel"), this); + dock->setObjectName("DockWidgetRemoteEdits"); + dock->setAllowedAreas(Qt::AllDockWidgetAreas); + dock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable + | QDockWidget::DockWidgetMovable); + addDockWidget(Qt::RightDockWidgetArea, dock); + dock->setWidget(w); + dock->hide(); + + connect(w, &RemoteEditPanel::sig_showRequested, dock, [dock]() { + dock->show(); + dock->raise(); + }); + + w->show(); + m_dockDialogRemoteEdits = dock; + }); + m_mumeClock = new MumeClock(getConfig().mumeClock.startEpoch, deref(m_gameObserver), this); if constexpr (!NO_UPDATER) { m_updateDialog = new UpdateDialog(this); @@ -637,6 +664,25 @@ void MainWindow::wireConnections() &FindRoomsDlg::sig_editSelection, this, &MainWindow::slot_onEditRoomSelection); + + connect(m_listener, &ConnectionListener::sig_proxyCreated, this, [this](QPointer proxy) { + if (!proxy) + return; + + connect(m_remoteEdit, &RemoteEdit::sig_sendGmcp, proxy.data(), &Proxy::slot_sendGmcp); + }); + + deref(m_gameObserver).sig2_sentToUserGmcp.connect(m_lifetime, [this](const GmcpMessage &msg) { + m_remoteEdit->slot_parseGmcpInput(msg); + }); + + deref(m_gameObserver).sig2_disconnected.connect(m_lifetime, [this]() { + m_remoteEdit->onDisconnected(); + }); + + deref(m_gameObserver).sig2_connected.connect(m_lifetime, [this]() { + m_remoteEdit->announcePendingDrafts(); + }); } void MainWindow::slot_log(const QString &mod, const QString &message) @@ -1366,6 +1412,7 @@ void MainWindow::setupMenuBar() sidepanels->addAction(m_dockDialogDescription->toggleViewAction()); sidepanels->addAction(m_dockDialogTimers->toggleViewAction()); sidepanels->addAction(m_dockDialogAsync->toggleViewAction()); + sidepanels->addAction(m_dockDialogRemoteEdits->toggleViewAction()); windowMenu->addSeparator(); windowMenu->addAction(showStatusBarAct); windowMenu->addAction(showScrollBarsAct); @@ -1793,6 +1840,10 @@ void MainWindow::closeEvent(QCloseEvent *const event) } asyncIO.setClosedForBusiness(); + // RemoteEdit sessions aren't registered with async_tasks (see RemoteEdit::shutdown() + // for why); tear them down here, synchronously, so no GMCP cancel is sent and every + // open edit/draft survives on disk for recovery on next launch. + m_remoteEdit->shutdown(); async_tasks::cancel_all(); /* note: this can only cancel tasks that allow it */ if (asyncIO.isRunningOnBackgroundThread()) { diff --git a/src/mainwindow/mainwindow.h b/src/mainwindow/mainwindow.h index cee2e19dd..bbb8ba352 100644 --- a/src/mainwindow/mainwindow.h +++ b/src/mainwindow/mainwindow.h @@ -69,6 +69,7 @@ class DescriptionWidget; class MediaLibrary; class TimerWidget; class MapDestination; +class RemoteEdit; struct MapLoadData; @@ -91,6 +92,7 @@ class NODISCARD_QOBJECT MainWindow final : public QMainWindow, public ProxyHost QDockWidget *m_dockDialogDescription = nullptr; QDockWidget *m_dockDialogTimers = nullptr; QDockWidget *m_dockDialogAsync = nullptr; + QDockWidget *m_dockDialogRemoteEdits = nullptr; std::unique_ptr m_gameObserver; AutoLogger *m_logger = nullptr; @@ -122,6 +124,7 @@ class NODISCARD_QOBJECT MainWindow final : public QMainWindow, public ProxyHost DescriptionWidget *m_descriptionWidget = nullptr; TimerWidget *m_timerWidget = nullptr; std::unique_ptr m_hotkeyManager; + RemoteEdit *m_remoteEdit = nullptr; QPointer m_contextMenu; @@ -297,6 +300,7 @@ class NODISCARD_QOBJECT MainWindow final : public QMainWindow, public ProxyHost NODISCARD HotkeyManager &getHotkeyManager() const { return deref(m_hotkeyManager); } NODISCARD CTimers &getTimers() const { return deref(m_timers); } + NODISCARD RemoteEdit &getRemoteEdit() const { return deref(m_remoteEdit); } private: // ProxyHost diff --git a/src/mpi/RemoteEditDraftStore.cpp b/src/mpi/RemoteEditDraftStore.cpp new file mode 100644 index 000000000..a3137c475 --- /dev/null +++ b/src/mpi/RemoteEditDraftStore.cpp @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 The MMapper Authors + +#include "RemoteEditDraftStore.h" + +#include "../global/TextUtils.h" +#include "../global/io.h" +#include "../global/random.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// MUME reuses small session ids, so the timestamp -- not just the id -- +// keeps concurrent/successive drafts from colliding. +NODISCARD QString makeKeyStem(const RemoteSessionId sessionId) +{ + return QString("%1_%2").arg(sessionId.asInt32()).arg(QDateTime::currentMSecsSinceEpoch()); +} + +NODISCARD QString encodeFileName(const RemoteSessionId sessionId, const QString &title) +{ + // Keep file names bounded by shortening the title itself, so neither a + // %XX escape nor a multi-byte character is ever cut in half. + constexpr qsizetype MAX_ENCODED_TITLE = 50; + QString shortened = title; + QByteArray encoded = QUrl::toPercentEncoding(shortened); + while (encoded.size() > MAX_ENCODED_TITLE && !shortened.isEmpty()) { + shortened.chop(1); + if (!shortened.isEmpty() && shortened.back().isHighSurrogate()) { + shortened.chop(1); + } + encoded = QUrl::toPercentEncoding(shortened); + } + return QString("draft_%1_%2.txt").arg(makeKeyStem(sessionId), QString::fromLatin1(encoded)); +} + +NODISCARD bool decodeFileName(const QString &fileName, RemoteSessionId &sessionId, QString &title) +{ + static const QRegularExpression re("^draft_(-?\\d+)_(\\d+)_(.*)\\.txt$"); + const QRegularExpressionMatch match = re.match(fileName); + if (!match.hasMatch()) { + return false; + } + sessionId = RemoteSessionId(match.captured(1).toInt()); + title = QUrl::fromPercentEncoding(match.captured(3).toUtf8()); + return true; +} + +constexpr const char *const SETTINGS_GROUP = "RemoteEditDrafts"; + +} // namespace + +RemoteEditDraftStore::~RemoteEditDraftStore() = default; + +// --------------------------------------------------------------------------- + +RemoteEditFileDraftStore::RemoteEditFileDraftStore(QString directory) + : m_directory(std::move(directory)) +{ + QDir().mkpath(m_directory); +} + +QString RemoteEditFileDraftStore::filePath(const QString &key) const +{ + if (key.isEmpty()) { + return QString(); + } + return QDir(m_directory).absoluteFilePath(key); +} + +QString RemoteEditFileDraftStore::create(const RemoteSessionId sessionId, + const QString &title, + const QString &content) +{ + QString fileName = encodeFileName(sessionId, title); + if (QFile::exists(filePath(fileName))) { + fileName = fileName.chopped(4) + "_" + mmqt::toQStringLatin1(getRandomString(5)) + ".txt"; + } + + QFile file(filePath(fileName)); + if (!file.open(QFile::WriteOnly | QFile::Text)) { + qWarning() << "Unable to create draft" << file.fileName(); + return QString(); + } + file.write(mmqt::toQByteArrayLatin1(content)); // MPI is always Latin1 + file.flush(); + std::ignore = io::fsyncNoexcept(file); + return fileName; +} + +bool RemoteEditFileDraftStore::save(const QString &key, const QString &content) +{ + if (key.isEmpty() || !QFile::exists(filePath(key))) { + return false; + } + QSaveFile file(filePath(key)); + if (!file.open(QFile::WriteOnly | QFile::Text)) { + return false; + } + file.write(mmqt::toQByteArrayLatin1(content)); + return file.commit(); +} + +QString RemoteEditFileDraftStore::read(const QString &key) const +{ + if (QFile file(filePath(key)); file.open(QFile::ReadOnly)) { + return QString::fromLatin1(file.readAll()); + } + qWarning() << "Unable to read draft" << filePath(key); + return QString(); +} + +void RemoteEditFileDraftStore::remove(const QString &key) +{ + if (key.isEmpty()) { + return; + } + if (!QFile::remove(filePath(key))) { + qWarning() << "Unable to remove draft" << filePath(key); + } +} + +QList RemoteEditFileDraftStore::list() const +{ + QList drafts; + const QDir dir(m_directory); + for (const QString &fileName : dir.entryList({"draft_*.txt"}, QDir::Files)) { + RemoteSessionId sessionId; + QString title; + if (decodeFileName(fileName, sessionId, title)) { + drafts.append({fileName, title, sessionId, QFileInfo(dir, fileName).lastModified()}); + } + } + return drafts; +} + +// --------------------------------------------------------------------------- + +RemoteEditSettingsDraftStore::RemoteEditSettingsDraftStore(SettingsFactory makeSettings) + : m_makeSettings(std::move(makeSettings)) +{} + +QString RemoteEditSettingsDraftStore::create(const RemoteSessionId sessionId, + const QString &title, + const QString &content) +{ + const QString key = makeKeyStem(sessionId) + "_" + mmqt::toQStringLatin1(getRandomString(5)); + auto settings = m_makeSettings(); + settings->beginGroup(SETTINGS_GROUP); + settings->beginGroup(key); + settings->setValue("sessionId", sessionId.asInt32()); + settings->setValue("title", title); + settings->setValue("content", content); + settings->setValue("lastModified", QDateTime::currentDateTime()); + settings->endGroup(); + settings->endGroup(); + settings->sync(); + return key; +} + +bool RemoteEditSettingsDraftStore::save(const QString &key, const QString &content) +{ + auto settings = m_makeSettings(); + settings->beginGroup(SETTINGS_GROUP); + settings->beginGroup(key); + if (!settings->contains("title")) { + return false; + } + settings->setValue("content", content); + settings->setValue("lastModified", QDateTime::currentDateTime()); + settings->endGroup(); + settings->endGroup(); + settings->sync(); + return settings->status() == QSettings::NoError; +} + +QString RemoteEditSettingsDraftStore::read(const QString &key) const +{ + auto settings = m_makeSettings(); + return settings->value(QString("%1/%2/content").arg(SETTINGS_GROUP, key)).toString(); +} + +void RemoteEditSettingsDraftStore::remove(const QString &key) +{ + if (key.isEmpty()) { + return; + } + auto settings = m_makeSettings(); + settings->beginGroup(SETTINGS_GROUP); + settings->remove(key); + settings->endGroup(); + settings->sync(); +} + +QList RemoteEditSettingsDraftStore::list() const +{ + QList drafts; + auto settings = m_makeSettings(); + settings->beginGroup(SETTINGS_GROUP); + for (const QString &key : settings->childGroups()) { + settings->beginGroup(key); + drafts.append({key, + settings->value("title").toString(), + RemoteSessionId(settings->value("sessionId").toInt()), + settings->value("lastModified").toDateTime()}); + settings->endGroup(); + } + settings->endGroup(); + return drafts; +} diff --git a/src/mpi/RemoteEditDraftStore.h b/src/mpi/RemoteEditDraftStore.h new file mode 100644 index 000000000..d8638896f --- /dev/null +++ b/src/mpi/RemoteEditDraftStore.h @@ -0,0 +1,74 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 The MMapper Authors + +#include "../global/macros.h" +#include "remoteeditsession.h" + +#include +#include + +#include +#include +#include + +/// Where unsent drafts live between (and across) sessions. A draft is +/// addressed by an opaque key handed out by create(). +class NODISCARD RemoteEditDraftStore +{ +public: + virtual ~RemoteEditDraftStore(); + + NODISCARD virtual QString create(RemoteSessionId sessionId, + const QString &title, + const QString &content) + = 0; + NODISCARD virtual bool save(const QString &key, const QString &content) = 0; + NODISCARD virtual QString read(const QString &key) const = 0; + virtual void remove(const QString &key) = 0; + NODISCARD virtual QList list() const = 0; + /// Absolute path of the draft, if this store keeps drafts as files that + /// an external editor can open; empty otherwise. + NODISCARD virtual QString filePath(const QString & /*key*/) const { return QString(); } +}; + +/// One file per draft under `directory`; the key is the file name. +class NODISCARD RemoteEditFileDraftStore final : public RemoteEditDraftStore +{ +private: + const QString m_directory; + +public: + explicit RemoteEditFileDraftStore(QString directory); + + NODISCARD QString create(RemoteSessionId sessionId, + const QString &title, + const QString &content) override; + NODISCARD bool save(const QString &key, const QString &content) override; + NODISCARD QString read(const QString &key) const override; + void remove(const QString &key) override; + NODISCARD QList list() const override; + NODISCARD QString filePath(const QString &key) const override; +}; + +/// One QSettings group per draft. On WebAssembly Qt persists QSettings to +/// browser storage, which is what makes drafts survive a page reload there. +class NODISCARD RemoteEditSettingsDraftStore final : public RemoteEditDraftStore +{ +public: + using SettingsFactory = std::function()>; + +private: + const SettingsFactory m_makeSettings; + +public: + explicit RemoteEditSettingsDraftStore(SettingsFactory makeSettings); + + NODISCARD QString create(RemoteSessionId sessionId, + const QString &title, + const QString &content) override; + NODISCARD bool save(const QString &key, const QString &content) override; + NODISCARD QString read(const QString &key) const override; + void remove(const QString &key) override; + NODISCARD QList list() const override; +}; diff --git a/src/mpi/RemoteEditPanel.cpp b/src/mpi/RemoteEditPanel.cpp new file mode 100644 index 000000000..e12c702ea --- /dev/null +++ b/src/mpi/RemoteEditPanel.cpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 The MMapper Authors + +#include "RemoteEditPanel.h" + +#include "../global/utils.h" +#include "remoteedit.h" +#include "remoteeditsession.h" +#include "remoteeditwidget.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +RemoteEditPanel::RemoteEditPanel(RemoteEdit &remoteEdit, QWidget *const parent) + : QWidget(parent) + , m_remoteEdit(remoteEdit) +{ + auto *const outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(0); + + m_list = new QWidget(this); + m_listLayout = new QVBoxLayout(m_list); + m_listLayout->setContentsMargins(8, 4, 8, 4); + m_listLayout->setSpacing(2); + outer->addWidget(m_list, 0); + + auto *const stackHost = new QWidget(this); + outer->addWidget(stackHost, 1); + m_stack = new QStackedLayout(stackHost); + m_stack->setContentsMargins(0, 0, 0, 0); + + m_placeholder = new QLabel(tr("No remote edits are open.\n" + "MUME will open pages here when you edit or view text."), + stackHost); + m_placeholder->setAlignment(Qt::AlignCenter); + m_placeholder->setWordWrap(true); + m_placeholder->setEnabled(false); + m_stack->addWidget(m_placeholder); + + m_tabs = new QTabWidget(stackHost); + m_tabs->setTabsClosable(true); + m_tabs->setMovable(true); + m_tabs->setDocumentMode(true); + m_tabs->setElideMode(Qt::ElideRight); + m_tabs->tabBar()->setExpanding(false); + m_stack->addWidget(m_tabs); + + connect(m_tabs, &QTabWidget::tabCloseRequested, this, [this](const int index) { + checked_dynamic_downcast(m_tabs->widget(index))->requestClose(); + }); + // QTabWidget drops a tab on its own when the page widget is destroyed; + // this keeps the placeholder in sync with that. + connect(m_tabs, &QTabWidget::currentChanged, this, [this]() { updatePlaceholder(); }); + + connect(&m_remoteEdit, &RemoteEdit::sig_sessionsChanged, this, &RemoteEditPanel::syncTabs); + connect(&m_remoteEdit, &RemoteEdit::sig_draftsChanged, this, &RemoteEditPanel::rebuildList); + + syncTabs(); +} + +void RemoteEditPanel::rebuildList() +{ + while (QLayoutItem *const item = m_listLayout->takeAt(0)) { + delete item->widget(); + delete item; + } + + using Button = std::pair>; + const auto addRow = [this](const QString &text, const std::vector