Skip to content
Pokered Save Editor 2
Pokemon Red & Blue save file editor - Qt 6 C++/QML
Loading...
Searching...
No Matches
mainwindow.cpp
Go to the documentation of this file.
1/*
2 * Copyright 2020 Twilight
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15*/
16
23#include <QQmlEngine>
24#include <QQmlContext>
25#include <QGuiApplication>
26#include <QScreen>
27#include <QElapsedTimer>
28#include <QDebug>
29#include <QMessageBox>
30#include <QImage>
31#include <QVariant>
32#include <QQuickItem>
33#include <QApplication>
34
35#ifdef QT_DEBUG
36#include <QQmlAbstractUrlInterceptor>
37#include <QFileSystemWatcher>
38#include <QDirIterator>
39#include <QFileInfo>
40#include <QDir>
41#include <QTimer>
42#include <QUrl>
43namespace {
44// Redirects qrc:/...qml|js URLs to the matching file on the source tree so the running
45// app loads QML from disk (enabling live reload). Falls back to qrc when the source
46// file isn't present. DEBUG-only.
47class QmlDiskInterceptor : public QQmlAbstractUrlInterceptor {
48public:
49 explicit QmlDiskInterceptor(const QString& root) : m_root(root) {}
50 QUrl intercept(const QUrl& url, DataType) override {
51 if(url.scheme() != QLatin1String("qrc")) return url;
52 const QString path = url.path(); // e.g. "/ui/app/App.qml"
53 if(!path.endsWith(QLatin1String(".qml")) && !path.endsWith(QLatin1String(".js")))
54 return url;
55 const QString disk = m_root + path;
56 if(QFileInfo::exists(disk)) return QUrl::fromLocalFile(disk);
57 return url;
58 }
59private:
60 QString m_root;
61};
62} // namespace
63#endif
64#include "mainwindow.h"
65
69
70#include <pse-common/types.h>
79
80#include <pse-db/db.h>
81#include <pse-db/fontsdb.h>
83
86
87MainWindow::MainWindow(QWidget *parent) :
88 QMainWindow(parent)
89{
90 QElapsedTimer t;
91 t.start();
92
93 // First setup UI
94 ui.setupUi(this);
95 qDebug() << "[MainWindow] setupUi —" << t.elapsed() << "ms";
96
97 // Save global class instance
98 MainWindow::instance = this;
99
100 // Create the file management class which kickstarts all the data classes and
101 // data management, etc... Basically a whole thing here lol
102 file = new FileManagement();
103 qDebug() << "[MainWindow] FileManagement —" << t.elapsed() << "ms";
104
105 // Inject several C++ class instances into QML
106 injectIntoQML();
107 qDebug() << "[MainWindow] injectIntoQML —" << t.elapsed() << "ms";
108
109 // Setup providers to QML
110 setupProviders();
111 qDebug() << "[MainWindow] setupProviders —" << t.elapsed() << "ms";
112
113 // Report QML load errors. In debug builds this shows a message box so
114 // problems are immediately visible during development; release builds stay
115 // silent (the app continues running as gracefully as it can).
116 connect(ui.app, &QQuickWidget::statusChanged, this, [this](QQuickWidget::Status status) {
117 if (status == QQuickWidget::Error) {
118 QString msg;
119 for (const auto& err : ui.app->errors())
120 msg += err.toString() + "\n";
121 qCritical() << "[QML]" << msg;
122#ifdef QT_DEBUG
123 QMessageBox::critical(this, "QML Error", msg);
124#endif
125 }
126 });
127
128#ifdef QT_DEBUG
129 // DEBUG (--hot): load QML from the source tree + watch it, so edits reload live.
130 // Must be installed BEFORE setSource so App.qml itself loads from disk.
131 if(QApplication::arguments().contains(QStringLiteral("--hot")))
132 setupHotReload();
133#endif
134
135 // Now load the QML page, has to be done after setup and injection
136 ui.app->setSource(QUrl(QStringLiteral("qrc:/ui/app/App.qml")));
137 qDebug() << "[MainWindow] setSource —" << t.elapsed() << "ms";
138
139 // Setup global shortcuts
140 setupShortcuts();
141
142 // Link up Signal & Slots
143 ssConnect();
144
145 // Initial setup
146 reUpdateRecentFiles(file->getRecentFiles());
147 onPathChanged(file->getPath());
148 loadState();
149 qDebug() << "[MainWindow] constructor done —" << t.elapsed() << "ms";
150}
151
153{
154 file->deleteLater();
155
156 for(var8 i = 0; i < MAX_RECENT_FILES; i++)
157 recentFileShortcuts[i]->deleteLater();
158
159 for(auto tmp : otherShortcuts)
160 tmp->deleteLater();
161}
162
163MainWindow* MainWindow::instance{nullptr};
164Bridge* MainWindow::bridge = nullptr;
165QQmlEngine* MainWindow::engine = nullptr;
166
168{
169 return MainWindow::instance;
170}
171
172// DEBUG helper: render the live QML view (the QQuickWidget's framebuffer) to an image
173// file. Works regardless of window focus/occlusion, so an automation harness can grab
174// the current screen without raising or activating the window. See --shot in
175// src/boot/debuglaunch.cpp.
176bool MainWindow::saveShot(const QString& path)
177{
178 const QImage img = ui.app->grabFramebuffer();
179 if(img.isNull())
180 return false;
181 return img.save(path);
182}
183
185{
186 QObject* root = ui.app->rootObject();
187 if(root == nullptr)
188 return false;
189 QObject* appWindow = root->findChild<QObject*>(QStringLiteral("appWindow"));
190 if(appWindow == nullptr)
191 return false;
192 QVariant ret;
193 const bool ok = QMetaObject::invokeMethod(
194 appWindow, "debugOpenPartyDetails",
195 Q_RETURN_ARG(QVariant, ret), Q_ARG(QVariant, index));
196 return ok && ret.toBool();
197}
198
200{
201 return ui.app->rootObject();
202}
203
204void MainWindow::setupHotReload()
205{
206#ifdef QT_DEBUG
207#ifdef PSE_QML_SOURCE_DIR
208 const QString root = QStringLiteral(PSE_QML_SOURCE_DIR);
209#else
210 const QString root;
211#endif
212 if(root.isEmpty() || !QDir(root + QStringLiteral("/ui")).exists()) {
213 qWarning() << "[hot-reload] QML source dir not found:" << root << "-- staying on qrc.";
214 return;
215 }
216 ui.app->engine()->addUrlInterceptor(new QmlDiskInterceptor(root));
217
218 m_qmlWatcher = new QFileSystemWatcher(this);
219 QStringList files;
220 QDirIterator it(root + QStringLiteral("/ui"), QStringList{ QStringLiteral("*.qml"), QStringLiteral("*.js") },
221 QDir::Files, QDirIterator::Subdirectories);
222 while(it.hasNext()) files << it.next();
223 if(!files.isEmpty()) m_qmlWatcher->addPaths(files);
224 connect(m_qmlWatcher, &QFileSystemWatcher::fileChanged, this, [this](const QString& p) {
225 // Editors often rewrite/rename on save, which drops the watch -- re-arm it.
226 if(!m_qmlWatcher->files().contains(p) && QFileInfo::exists(p))
227 m_qmlWatcher->addPath(p);
228 if(m_reloadPending) return; // debounce a burst of change events
229 m_reloadPending = true;
230 QTimer::singleShot(160, this, [this]{ m_reloadPending = false; reloadQml(); });
231 });
232 qInfo() << "[hot-reload] watching" << files.size() << "QML files under" << (root + QStringLiteral("/ui"));
233#endif
234}
235
237{
238#ifdef QT_DEBUG
239 // A hot-reload rebuilds the WHOLE QML tree from App.qml (there is no safe per-file
240 // partial reload in a single-QQuickWidget app -- the engine caches whole compiled
241 // components, so reloading only the changed file would leave consumers of a shared
242 // component holding a stale cached copy, a subtle correctness hazard we refuse). To
243 // keep the fast-iteration experience without that hazard, we do the full, reliable
244 // reload but RESTORE CONTEXT afterwards: the loaded save already survives untouched
245 // (it lives in C++ -- Bridge/FileManagement -- not QML), so we only need to return
246 // the user to the screen they were editing instead of dumping them at Home + the
247 // New File modal.
248
249 // 1) Remember the current screen (by name) before we tear the tree down. The top of
250 // the nav stack is a Screen*; reverse-look-up its registered name.
251 QString currentName;
252 if(!Router::stack.isEmpty()) {
253 Screen* top = Router::stack.last();
254 for(auto it = Router::screens.cbegin(); it != Router::screens.cend(); ++it)
255 if(it.value() == top) { currentName = it.key(); break; }
256 }
257
258 // 2) Full reload.
259 if(engine) engine->clearComponentCache();
260 const QUrl src = ui.app->source();
261 ui.app->setSource(QUrl()); // tear down the current tree
262 ui.app->setSource(src.isValid() ? src : QUrl(QStringLiteral("qrc:/ui/app/App.qml")));
263 qInfo() << "[hot-reload] reloaded" << (src.isValid() ? src.toString() : QStringLiteral("App.qml"))
264 << "-- restoring screen:" << (currentName.isEmpty() ? QStringLiteral("(none)") : currentName);
265
266 // 3) Only restore a real, non-modal screen. Home needs no action (App.qml lands
267 // there), and we don't auto-reopen a modal (the startup New File modal is
268 // legitimately shown when no save/target is active).
269 const bool restore = !currentName.isEmpty()
270 && currentName != QStringLiteral("home")
271 && Router::screens.contains(currentName)
272 && Router::screens.value(currentName) != nullptr
273 && !Router::screens.value(currentName)->modal;
274 if(!restore)
275 return;
276
277 // 4) App.qml re-seats its startup stack (home + New File modal) on a later event-
278 // loop tick, so poll until it's seated, then dismiss the modal and navigate back
279 // (same sequencing as the --screen debug-launch flag). pokemonDetails needs a
280 // selection, so re-open party mon 0 rather than a bare changeScreen().
281 QTimer* timer = new QTimer(this);
282 int* tries = new int(0);
283 const QString target = currentName;
284 connect(timer, &QTimer::timeout, this, [this, timer, tries, target]() {
286 const bool ready = (brg != nullptr) && Router::stack.size() >= 2;
287 if(!ready && ++(*tries) < 200) // wait up to ~10s (50ms x 200)
288 return;
289 timer->stop();
290 timer->deleteLater();
291 delete tries;
292 if(brg == nullptr)
293 return;
294 brg->router->closeScreen(); // dismiss the startup New File modal
295 if(target == QStringLiteral("pokemonDetails"))
296 debugOpenPartyDetails(0); // needs a selected mon
297 else
298 brg->router->changeScreen(target); // back to where we were
299 qInfo() << "[hot-reload] restored screen" << target;
300 });
301 timer->setInterval(50);
302 timer->start();
303#endif
304}
305
306void MainWindow::reUpdateRecentFiles(QList<QString> files)
307{
308 // Disable all recent files shortcuts
309 for(var8 i{0}; i < MAX_RECENT_FILES; i++) {
310 recentFileShortcuts[i]->setEnabled(false);
311 }
312
313 // Re-enable them based on how many recent files
314 for(var8 i{0}; i < MAX_RECENT_FILES && i < files.size(); i++) {
315 QString file{files.at(i)};
316 if(file == "")
317 continue;
318
319 recentFileShortcuts[i]->setEnabled(true);
320 }
321}
322
323void MainWindow::onRecentFileClick()
324{
325 QShortcut* shortcut{qobject_cast<QShortcut*>(sender())};
326 var8 index{static_cast<var8>(shortcut->property("index").toInt())};
327 file->openFileRecent(index);
328}
329
330void MainWindow::onPathChanged(QString path)
331{
332 if(path == "")
333 this->setWindowTitle("Pokered Save Editor - New File");
334 else
335 this->setWindowTitle("Pokered Save Editor - " + path);
336}
337
338void MainWindow::closeEvent(QCloseEvent *event)
339{
340 this->saveState();
341 event->accept();
342}
343
344void MainWindow::saveState()
345{
346 settings.beginGroup("WindowState");
347 settings.setValue("size", this->size());
348 settings.setValue("pos", this->pos());
349 settings.endGroup();
350}
351
352void MainWindow::loadState()
353{
354 settings.beginGroup("WindowState");
355 QSize savedSize = settings.value("size", QSize(1130, 740)).toSize();
356 QPoint savedPos = settings.value("pos", QPoint(200, 200)).toPoint();
357 settings.endGroup();
358
359 this->resize(savedSize);
360
361 // Guard against off-screen positions (e.g. disconnected monitor).
362 // Accept the saved position only if the title bar area is on some screen.
363 bool onScreen = false;
364 const QPoint titleBarPt = savedPos + QPoint(savedSize.width() / 2, 10);
365 for (const QScreen* screen : QGuiApplication::screens()) {
366 if (screen->availableGeometry().contains(titleBarPt)) {
367 onScreen = true;
368 break;
369 }
370 }
371 this->move(onScreen ? savedPos : QPoint(200, 200));
372}
373
374void MainWindow::setupShortcuts()
375{
376 // Create recent files shortcut (Ctrl+Shift+0..4 -> open recent file 0..Max).
377 for(var8 i = 0; i < MAX_RECENT_FILES; i++) {
378
379 // Create and link up a shortcut
380 // Ensure it's disabled, assign a shortcut, and assign the index to it
381 recentFileShortcuts[i] = new QShortcut(this);
382 recentFileShortcuts[i]->setEnabled(false);
384 recentFileShortcuts[i]->setProperty("index", i);
385 connect(recentFileShortcuts[i], &QShortcut::activated, this, &MainWindow::onRecentFileClick);
386 }
387
388 // Create and link up other shortcuts. Key sequences come from the shared
389 // pse::shortcutKeyMap() (single source of truth, asserted by tst_shortcuts);
390 // the action→verb wiring stays here. NB: write into the `otherShortcuts` MEMBER
391 // directly -- the previous `auto os = otherShortcuts;` copied it, so the member
392 // was left empty (the shortcuts only survived via their QObject parent).
393 auto& os = otherShortcuts;
394 const auto keymap = pse::shortcutKeyMap();
395 for (auto it = keymap.constBegin(); it != keymap.constEnd(); ++it)
396 os.insert(it.key(), new QShortcut(it.value(), this));
397
398 // Wire each shortcut to its verb via the shared pse::shortcutActions() map (the
399 // single source of truth tst_shortcuts fires against). exit/exit2 close the window.
400 const auto actions = pse::shortcutActions(file, [this]{ close(); });
401 for (auto it = actions.constBegin(); it != actions.constEnd(); ++it) {
402 QShortcut* sc = os.value(it.key(), nullptr);
403 if (!sc) continue;
404 const std::function<void()> verb = it.value();
405 connect(sc, &QShortcut::activated, this, [verb]{ verb(); });
406 }
407}
408
409void MainWindow::setupProviders()
410{
411 auto engine = ui.app->engine();
412 engine->addImageProvider("tileset", new TilesetProvider);
413 engine->addImageProvider("font", new FontPreviewProvider(file->data->dataExpanded));
414}
415
416void MainWindow::injectIntoQML()
417{
418 auto* context = ui.app->rootContext();
419 bridge = new Bridge(file);
420 context->setContextProperty("brg", bridge);
421 MainWindow::engine = ui.app->engine();
422
423 // Protect every DB entry from QML's garbage collector (s13f). DB::qmlProtect
424 // cascades CppOwnership to all sub-DB entries; without it QML GCs the shared,
425 // parentless FontDBEntry (and other) objects mid-session — fonts blank out,
426 // picker pills go red/empty, and names stop saving until an app reboot.
428}
429
430void MainWindow::ssConnect()
431{
432 connect(file, &FileManagement::pathChanged, this, &MainWindow::onPathChanged);
433 connect(file, &FileManagement::recentFilesChanged, this, &MainWindow::reUpdateRecentFiles);
434}
435
The single QML<->C++ doorway – everything the UI touches hangs off here.
Definition bridge.h:71
Router * router
Definition bridge.h:153
void qmlProtect(const QQmlEngine *const engine) const
Pin the DB aggregate (and every sub-DB) to C++ ownership so QML never GCs them.
Definition db.cpp:192
static DB * inst()
< Raw parsed JSON assets behind every DB.
Definition db.cpp:33
Owns the on-disk side of a save: the current path, the recent-files list, and the live SaveFile.
protected::void pathChanged(QString newPath, QString oldPath)
The active path changed.
void recentFilesChanged(QList< QString > files)
The recent-files list changed.
The top-level window – a QMainWindow hosting the QML UI in a QQuickWidget.
Definition mainwindow.h:52
bool saveShot(const QString &path)
DEBUG: render the live QML view to an image file (focus/occlusion-independent).
bool debugOpenPartyDetails(int index)
DEBUG: open the details editor for party mon index (drives the QML AppWindow.debugOpenPartyDetails).
static QQmlEngine * engine
The QML engine behind the hosted QQuickWidget.
Definition mainwindow.h:63
QShortcut * recentFileShortcuts[5]
Ctrl+1..5 open-recent shortcuts.
Definition mainwindow.h:88
QHash< QString, QShortcut * > otherShortcuts
Other global keyboard shortcuts by name.
Definition mainwindow.h:89
QObject * qmlRootObject()
DEBUG: the root QML object of the hosted view (for the debug control server's object lookups).
FileManagement * file
Definition mainwindow.h:85
MainWindow(QWidget *parent=nullptr)
< The live save controller.
static MainWindow * getInstance()
The single MainWindow instance.
virtual ~MainWindow()
static Bridge * bridge
The brg aggregate (created here, injected into QML).
Definition mainwindow.h:62
void reloadQml()
DEBUG (–hot): clear the QML cache and reload the view from the source files on disk (live QML refresh...
void changeScreen(QString name)
Navigate to the registered screen name.
Definition router.cpp:35
static QVector< Screen * > stack
The live navigation stack.
Definition router.h:103
void closeScreen()
Close the top screen.
Definition router.cpp:78
static QHash< QString, Screen * > screens
The registry of named screens.
Definition router.h:104
Project-wide fixed-width integer aliases (var8, var16, ...).
var8e var8
Everyday 8-bit alias. Exact (not "fastest") to dodge the pointer-width bug noted above.
Definition types.h:124
constexpr var8 MAX_RECENT_FILES
How many recent paths to remember.
QHash< QString, QKeySequence > shortcutKeyMap()
Named global shortcuts: action id -> key sequence.
QHash< QString, std::function< void()> > shortcutActions(FileManagement *file, std::function< void()> onExit)
What each named shortcut DOES, action id -> callable, over a live FileManagement.
QKeySequence recentFileShortcutKey(int i)
Recent-file shortcut i (0..MAX_RECENT_FILES-1) == Ctrl+Shift+(0+i), i.e.
Single source of truth for the global keyboard shortcut KEY SEQUENCES.
One registered screen: its QML url, title, and modal/home-button flags.
Definition router.h:32