From 7244ee05b812d77b5abeb71969348675d8ba32a3 Mon Sep 17 00:00:00 2001 From: "Kanishka.." Date: Thu, 16 Jul 2026 01:43:53 +0530 Subject: [PATCH] Made the World Tool useful for maps that are not part of a world (#4562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resize handles now also work on maps that are not part of any world. Resizing only changes the map itself, so it does not need a world, and undo goes through the map's own undo stack. The view is kept steady by compensating for the content shift, which now uses the same computation whether or not the map is in a world. Arrow keys scroll the view when the map cannot be moved. Added an explicit "New World for Map" action to the World Tool, which asks for a world file name and creates a new world containing the current map. It is shown in place of the "Add another map" action when the current map is not in a world, guiding the user to create one first. The New World dialog is now shared with the "World > New World" menu action through MainWindow::createNewWorld, which suggests a file name based on the map and explicitly handles selecting an already loaded world. Also fixed the session forgetting its loaded worlds in some cases, by remembering them right before switching session or quitting, and fixed newly created worlds having an empty canonical file path. Co-authored-by: Thorbjørn Lindeijer --- NEWS.md | 3 +- src/tiled/abstractworldtool.cpp | 73 +++++++++++++++++++++++-- src/tiled/abstractworldtool.h | 6 +++ src/tiled/mainwindow.cpp | 95 +++++++++++++++++++++++---------- src/tiled/mainwindow.h | 2 + src/tiled/worldmanager.cpp | 23 ++++---- src/tiled/worldmovemaptool.cpp | 28 ++++++---- 7 files changed, 175 insertions(+), 55 deletions(-) diff --git a/NEWS.md b/NEWS.md index 2566a53808..754f0e6187 100644 --- a/NEWS.md +++ b/NEWS.md @@ -3,7 +3,8 @@ * Added 'Collapse All' action and 'Only Expand to Current' mode to Project view (with rhythmcache, #4346) * Added command variables %exportfile and %exportpath (#4476) * Added a configurable world grid with snapping for maps (by Kanishka, #4534) -* Added drag-to-resize for maps in the world view (by Kanishka, #4545) +* Added drag-to-resize for maps in the world view, usable even when they are not part of a world (by Kanishka, #4545, #4562) +* Added an action to create a world containing the current map (by Kanishka, #4562) * Made switching to the previously selected tool when pressing its shortcut again optional and off by default (by dogboydog, #4540) * Persisted collapsed state of the properties groups in the session (#4561) * Scripting: Added 'tiled.cell' function, 'cell.flags' property and 'TileLayerEdit.setCell' function (#4538) diff --git a/src/tiled/abstractworldtool.cpp b/src/tiled/abstractworldtool.cpp index 7924d23741..8f850510f6 100644 --- a/src/tiled/abstractworldtool.cpp +++ b/src/tiled/abstractworldtool.cpp @@ -122,6 +122,12 @@ AbstractWorldTool::AbstractWorldTool(Id id, WorldManager &worldManager = WorldManager::instance(); connect(&worldManager, &WorldManager::worldsChanged, this, &AbstractWorldTool::updateEnabledState); + QIcon newWorldForMapIcon(QLatin1String(":images/24/world-map-add-other.png")); + mNewWorldForMapAction = new QAction(this); + mNewWorldForMapAction->setIcon(newWorldForMapIcon); + ActionManager::registerAction(mNewWorldForMapAction, "NewWorldForMap"); + connect(mNewWorldForMapAction, &QAction::triggered, this, &AbstractWorldTool::createWorldForCurrentMap); + QIcon addAnotherMapToWorldIcon(QLatin1String(":images/24/world-map-add-other.png")); mAddAnotherMapToWorldAction = new QAction(this); mAddAnotherMapToWorldAction->setIcon(addAnotherMapToWorldIcon); @@ -202,21 +208,46 @@ void AbstractWorldTool::languageChanged() void AbstractWorldTool::languageChangedImpl() { + mNewWorldForMapAction->setText(tr("Create a new world containing the current map")); mAddAnotherMapToWorldAction->setText(tr("Add another map to the current world")); mAddMapToWorldAction->setText(tr("Add the current map to a loaded world")); mRemoveMapFromWorldAction->setText(tr("Remove the current map from the current world")); } +void AbstractWorldTool::mapDocumentChanged(MapDocument *oldDocument, + MapDocument *newDocument) +{ + // The enabled state of the actions depends on the map's file name + if (oldDocument) + disconnect(oldDocument, &Document::fileNameChanged, + this, &AbstractWorldTool::updateEnabledState); + if (newDocument) + connect(newDocument, &Document::fileNameChanged, + this, &AbstractWorldTool::updateEnabledState); +} + void AbstractWorldTool::updateEnabledState() { const bool hasWorlds = !WorldManager::instance().worlds().isEmpty(); - const auto worldDocument = worldForMap(mapDocument()); - setEnabled(mapDocument() && hasWorlds && (!worldDocument || worldDocument->world()->canBeModified())); + MapDocument *map = mapDocument(); + const auto worldDocument = worldForMap(map); + + // Maps that are not in a world can still be resized + setEnabled(mapCanBeResized(map)); + + // When the map is not in a world, only the action for creating a new + // world is shown, which guides the user to create one first + mNewWorldForMapAction->setVisible(!worldDocument); + mNewWorldForMapAction->setEnabled(map && !map->fileName().isEmpty() && !worldDocument); - // update toolbar actions + mAddAnotherMapToWorldAction->setVisible(worldDocument); + mAddAnotherMapToWorldAction->setEnabled(worldDocument); + + mAddMapToWorldAction->setVisible(!worldDocument); mAddMapToWorldAction->setEnabled(hasWorlds && !worldDocument); + + mRemoveMapFromWorldAction->setVisible(worldDocument); mRemoveMapFromWorldAction->setEnabled(worldDocument); - mAddAnotherMapToWorldAction->setEnabled(worldDocument); } MapDocument *AbstractWorldTool::mapAt(const QPointF &pos) const @@ -273,6 +304,15 @@ bool AbstractWorldTool::mapCanBeMoved(MapDocument *mapDocument) const return worldDocument && worldDocument->world()->canBeModified(); } +// Resizing only changes the map itself, so it also works without a world +bool AbstractWorldTool::mapCanBeResized(MapDocument *mapDocument) const +{ + if (!mapDocument) + return false; + auto worldDocument = worldForMap(mapDocument); + return !worldDocument || worldDocument->world()->canBeModified(); +} + QRect AbstractWorldTool::mapRect(MapDocument *mapDocument) const { auto rect = mapDocument->renderer()->mapBoundingRect(); @@ -316,6 +356,12 @@ void AbstractWorldTool::showContextMenu(QGraphicsSceneMouseEvent *event) this, [=] { removeFromWorld(currentWorldDocument, targetFilename); }); } } else { + menu.addAction(QIcon(QLatin1String(":images/24/world-map-add-other.png")), + tr("New World Containing \"%1\"") + .arg(mapDocument()->displayName()), + this, &AbstractWorldTool::createWorldForCurrentMap) + ->setEnabled(!mapDocument()->fileName().isEmpty()); + populateAddToWorldMenu(menu); } @@ -381,6 +427,24 @@ void AbstractWorldTool::addAnotherMapToWorld(QPoint insertPos) undoStack->push(new AddMapCommand(worldDocument, fileName, rect)); } +// Asks for a file name and creates a new world containing the current map. +// Does nothing when the map is already part of a world, the user cancels or +// the world could not be saved. +void AbstractWorldTool::createWorldForCurrentMap() +{ + MapDocument *map = mapDocument(); + if (!map || map->fileName().isEmpty() || worldForMap(map)) + return; + + const QFileInfo fileInfo(map->fileName()); + const QString suggestedFileName + = fileInfo.dir().filePath(fileInfo.completeBaseName() + + QStringLiteral(".world")); + + if (auto worldDocument = MainWindow::instance()->createNewWorld(suggestedFileName)) + addToWorld(worldDocument); +} + void AbstractWorldTool::removeCurrentMapFromWorld() { if (auto currentWorldDocument = worldForMap(mapDocument())) @@ -424,6 +488,7 @@ QUndoStack *AbstractWorldTool::undoStack() void AbstractWorldTool::populateToolBar(QToolBar *toolBar) { + toolBar->addAction(mNewWorldForMapAction); toolBar->addAction(mAddAnotherMapToWorldAction); toolBar->addAction(mAddMapToWorldAction); toolBar->addAction(mRemoveMapFromWorldAction); diff --git a/src/tiled/abstractworldtool.h b/src/tiled/abstractworldtool.h index b16f877117..694daba761 100644 --- a/src/tiled/abstractworldtool.h +++ b/src/tiled/abstractworldtool.h @@ -91,10 +91,14 @@ class AbstractWorldTool : public AbstractTool */ void updateEnabledState() override; + void mapDocumentChanged(MapDocument *oldDocument, + MapDocument *newDocument) override; + MapDocument *mapAt(const QPointF &pos) const; void addAnotherMapToWorldAtCenter(); void addAnotherMapToWorld(QPoint insertPos); + void createWorldForCurrentMap(); void removeCurrentMapFromWorld(); void removeFromWorld(WorldDocument *worldDocument, const QString &mapFileName); void addToWorld(WorldDocument *worldDocument); @@ -111,6 +115,7 @@ class AbstractWorldTool : public AbstractTool void recenterView(const QPoint &offset); bool mapCanBeMoved(MapDocument *mapDocument) const; + bool mapCanBeResized(MapDocument *mapDocument) const; QRect mapRect(MapDocument *mapDocument) const; WorldDocument *worldForMap(MapDocument *mapDocument) const; @@ -123,6 +128,7 @@ class AbstractWorldTool : public AbstractTool MapDocument *mTargetMap = nullptr; + QAction *mNewWorldForMapAction; QAction *mAddAnotherMapToWorldAction; QAction *mAddMapToWorldAction; QAction *mRemoveMapFromWorldAction; diff --git a/src/tiled/mainwindow.cpp b/src/tiled/mainwindow.cpp index 2438042c05..c05b77ec77 100644 --- a/src/tiled/mainwindow.cpp +++ b/src/tiled/mainwindow.cpp @@ -592,6 +592,12 @@ MainWindow::MainWindow(QWidget *parent, Qt::WindowFlags flags) this, &MainWindow::addExternalTileset); connect(mUi->actionAddAutomappingRulesTileset, &QAction::triggered, this, &MainWindow::addAutomappingRulesTileset); + + // Remember the loaded worlds before switching session or quitting + connect(preferences, &Preferences::aboutToSwitchSession, this, [this] { + mLoadedWorlds = WorldManager::instance().worldFileNames(); + }); + connect(mUi->actionLoadWorld, &QAction::triggered, this, [this] { Session &session = Session::current(); QString lastPath = session.lastPath(Session::WorldFile); @@ -608,8 +614,6 @@ MainWindow::MainWindow(QWidget *parent, Qt::WindowFlags flags) QString errorString; if (!WorldManager::instance().loadWorld(worldFile, &errorString)) QMessageBox::critical(this, tr("Error Loading World"), errorString); - else - mLoadedWorlds = WorldManager::instance().worldFileNames(); }); connect(mUi->menuUnloadWorld, &QMenu::aboutToShow, this, [this] { mUi->menuUnloadWorld->clear(); @@ -624,7 +628,6 @@ MainWindow::MainWindow(QWidget *parent, Qt::WindowFlags flags) return; WorldManager::instance().unloadWorld(worldDocument); - mLoadedWorlds = WorldManager::instance().worldFileNames(); }); } if (WorldManager::instance().worlds().count() >= 2) { @@ -633,30 +636,7 @@ MainWindow::MainWindow(QWidget *parent, Qt::WindowFlags flags) } }); connect(mUi->actionNewWorld, &QAction::triggered, this, [this] { - Session &session = Session::current(); - QString lastPath = session.lastPath(Session::WorldFile); - QString filter = tr("All Files (*)"); - filter.append(QStringLiteral(";;")); - QString worldFilesFilter = tr("World files (*.world)"); - filter.append(worldFilesFilter); - QString worldFile; - - QFileDialog dialog(this, tr("New World"), lastPath, filter); - dialog.setAcceptMode(QFileDialog::AcceptSave); - dialog.selectNameFilter(worldFilesFilter); - dialog.setDefaultSuffix(QStringLiteral("world")); - if (dialog.exec() == QDialog::Accepted) - worldFile = dialog.selectedFiles().value(0); - - if (worldFile.isEmpty()) - return; - - session.setLastPath(Session::WorldFile, QFileInfo(worldFile).path()); - QString errorString; - if (!WorldManager::instance().addEmptyWorld(worldFile, &errorString)) - QMessageBox::critical(this, tr("Error Creating World"), errorString); - else - mLoadedWorlds = WorldManager::instance().worldFileNames(); + createNewWorld(); }); connect(mUi->menuSaveWorld, &QMenu::aboutToShow, this, [this] { mUi->menuSaveWorld->clear(); @@ -1072,8 +1052,6 @@ bool MainWindow::openFile(const QString &fileName, FileFormat *fileFormat) QMessageBox::critical(this, tr("Error Loading World"), errorString); return false; } else { - mLoadedWorlds = worldManager.worldFileNames(); - Document *document = mDocumentManager->currentDocument(); if (document && document->type() == Document::MapDocumentType) if (worldManager.worldForMap(document->fileName()) == worldDocument) @@ -1316,6 +1294,65 @@ bool MainWindow::confirmSaveWorld(WorldDocument *worldDocument) } } +/** + * Asks for a file name and creates a new empty world. When \a + * suggestedFileName is empty, the last used world path is suggested. + * + * @return the created world, or null when the user canceled or the world + * could not be created + */ +WorldDocument *MainWindow::createNewWorld(const QString &suggestedFileName) +{ + Session &session = Session::current(); + const QString startingLocation = suggestedFileName.isEmpty() + ? session.lastPath(Session::WorldFile) + : suggestedFileName; + + QString filter = tr("All Files (*)"); + filter.append(QStringLiteral(";;")); + QString worldFilesFilter = tr("World files (*.world)"); + filter.append(worldFilesFilter); + + QFileDialog dialog(this, tr("New World"), startingLocation, filter); + dialog.setAcceptMode(QFileDialog::AcceptSave); + dialog.selectNameFilter(worldFilesFilter); + dialog.setDefaultSuffix(QStringLiteral("world")); + + QString worldFile; + if (dialog.exec() == QDialog::Accepted) + worldFile = dialog.selectedFiles().value(0); + + if (worldFile.isEmpty()) + return nullptr; + + session.setLastPath(Session::WorldFile, QFileInfo(worldFile).path()); + + // When the selected world is already loaded, use it rather than + // reporting an error + if (auto worldDocument = WorldManager::instance().findWorld(worldFile)) { + if (!worldDocument->world()->canBeModified()) { + QMessageBox::critical(this, tr("Error Creating World"), + tr("World \"%1\" is already loaded and cannot be modified.") + .arg(worldDocument->displayName())); + return nullptr; + } + + QMessageBox::information(this, tr("New World"), + tr("Using already loaded world \"%1\".") + .arg(worldDocument->displayName())); + return worldDocument.data(); + } + + QString errorString; + auto worldDocument = WorldManager::instance().addEmptyWorld(worldFile, &errorString); + if (!worldDocument) { + QMessageBox::critical(this, tr("Error Creating World"), errorString); + return nullptr; + } + + return worldDocument.data(); +} + void MainWindow::export_() { if (!exportDocument(mDocument)) { diff --git a/src/tiled/mainwindow.h b/src/tiled/mainwindow.h index 3020f89284..8c1b7a4788 100644 --- a/src/tiled/mainwindow.h +++ b/src/tiled/mainwindow.h @@ -96,6 +96,8 @@ class TILED_EDITOR_EXPORT MainWindow : public QMainWindow */ bool openFile(const QString &fileName, FileFormat *fileFormat = nullptr); + WorldDocument *createNewWorld(const QString &suggestedFileName = QString()); + bool addRecentProjectsActions(QMenu *menu) const; static MainWindow *instance(); diff --git a/src/tiled/worldmanager.cpp b/src/tiled/worldmanager.cpp index 6b6def9991..cf4bf624ec 100644 --- a/src/tiled/worldmanager.cpp +++ b/src/tiled/worldmanager.cpp @@ -52,6 +52,8 @@ void WorldManager::deleteInstance() WorldDocumentPtr WorldManager::findWorld(const QString &fileName) const { const auto canonicalFilePath = QFileInfo(fileName).canonicalFilePath(); + if (canonicalFilePath.isEmpty()) // file doesn't exist + return {}; for (auto &worldDocument : mWorldDocuments) if (worldDocument->canonicalFilePath() == canonicalFilePath) return worldDocument; @@ -68,20 +70,21 @@ WorldDocumentPtr WorldManager::addEmptyWorld(const QString &fileName, QString *e auto world = std::make_unique(); world->fileName = fileName; - auto worldDocument = WorldDocumentPtr::create(std::move(world)); - if (worldDocument->save(worldDocument->fileName(), errorString)) { - mWorldDocuments.append(worldDocument); + // Save before creating the document, so that the canonical file path is + // available when the document is constructed + if (!World::save(*world, errorString)) + return {}; - connect(worldDocument.data(), &WorldDocument::worldChanged, - this, [this] { emit worldsChanged(); }); + auto worldDocument = WorldDocumentPtr::create(std::move(world)); + mWorldDocuments.append(worldDocument); - emit worldLoaded(worldDocument.data()); - emit worldsChanged(); - return worldDocument; - } + connect(worldDocument.data(), &WorldDocument::worldChanged, + this, [this] { emit worldsChanged(); }); - return {}; + emit worldLoaded(worldDocument.data()); + emit worldsChanged(); + return worldDocument; } /** diff --git a/src/tiled/worldmovemaptool.cpp b/src/tiled/worldmovemaptool.cpp index 0346db44a8..0cd9ada986 100644 --- a/src/tiled/worldmovemaptool.cpp +++ b/src/tiled/worldmovemaptool.cpp @@ -104,8 +104,10 @@ void WorldMoveMapTool::keyPressed(QKeyEvent *event) return; } MapDocument *document = mapDocument(); - if (!document || !mapCanBeMoved(document) || mDraggingMap) + if (!document || !mapCanBeMoved(document) || mDraggingMap) { + event->ignore(); // allow the view to scroll instead return; + } const bool moveFast = modifiers & Qt::ShiftModifier; if (moveFast) @@ -210,7 +212,7 @@ void WorldMoveMapTool::mousePressed(QGraphicsSceneMouseEvent *event) if (event->button() == Qt::LeftButton) { MapDocument *map = nullptr; const int handle = resizeHandleNear(event->scenePos(), map); - if (handle != -1 && mapCanBeMoved(map)) { + if (handle != -1 && mapCanBeResized(map)) { startResizing(map, handle, event->scenePos()); return; } @@ -231,8 +233,11 @@ void WorldMoveMapTool::startResizing(MapDocument *map, int handle, mResizeHandle = handle; mDragStartScenePos = scenePos; - auto world = worldForMap(mResizingMap)->world(); - const QPoint worldPos = world->mapRect(mResizingMap->fileName()).topLeft(); + // For maps that are not in a world the position is just 0,0 + QPoint worldPos; + if (auto worldDocument = worldForMap(mResizingMap)) + worldPos = worldDocument->world()->mapRect(mResizingMap->fileName()).topLeft(); + const QSize sizePixels = mResizingMap->renderer()->mapBoundingRect().size(); mResizeStartWorldRect = QRect(worldPos, sizePixels); mResizeSceneOffset = mapScene()->mapItem(mResizingMap)->pos().toPoint() - worldPos; @@ -263,7 +268,7 @@ void WorldMoveMapTool::mouseMoved(const QPointF &pos, // target the map whose handle is under the cursor, else hover normally MapDocument *map = nullptr; const int hoveredHandle = resizeHandleNear(pos, map); - if (hoveredHandle != -1 && mapCanBeMoved(map)) + if (hoveredHandle != -1 && mapCanBeResized(map)) setTargetMap(map); else AbstractWorldTool::mouseMoved(pos, modifiers); @@ -322,15 +327,16 @@ void WorldMoveMapTool::finishResizing() mResizeHandle = -1; if (mResizeNewSize != resizedMap->map()->size() || !mResizeOffset.isNull()) { - const QPoint prevPos = mResizeStartWorldRect.topLeft(); resizedMap->resizeMap(mResizeNewSize, mResizeOffset, false); - // keep the view steady when the active map's position shifted + // keep the view steady by compensating for the content shift, which + // matches the pixelOffset applied by MapDocument::resizeMap (a map + // in a world has its position adjusted by exactly this amount) if (resizedMap == mapDocument()) { - if (auto worldDocument = worldForMap(resizedMap)) { - const QPoint newPos = worldDocument->world()->mapRect(resizedMap->fileName()).topLeft(); - recenterView(newPos - prevPos); - } + const MapRenderer *renderer = resizedMap->renderer(); + const QPointF pixelOffset = renderer->tileToPixelCoords(QPointF()) + - renderer->tileToPixelCoords(-mResizeOffset); + recenterView(-pixelOffset.toPoint()); } }