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()); } }