From 8537a3cf0a4a97e45d6557e28aced32b49f97770 Mon Sep 17 00:00:00 2001 From: Si Date: Sat, 26 Sep 2026 20:24:10 +0200 Subject: [PATCH] Open Save in the project folder and offer the last name with a number counted on Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ src/core/project.cpp | 18 ++++++++++++++++++ src/core/project.hpp | 5 +++++ src/gui/window.cpp | 26 ++++++++++++++++++++++++-- src/gui/window.hpp | 3 +++ tests/core_tests.cpp | 15 +++++++++++++++ 6 files changed, 68 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7238650..64f2af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ commit that made it. The interface shows this file under Changelog in its menu. plays that draft, each frame held for three, then fills in the frames between while it plays. The finished preview and the saved video are unchanged. +- Save opens in the project's folder, and after the first save it offers the + last saved file's name with a number counted on (`city.png`, then + `city-1.png`, `city-2.png`), skipping names already taken. ## [0.7.0] - 2026-09-26 diff --git a/src/core/project.cpp b/src/core/project.cpp index 2a20b97..bb1e266 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -672,4 +672,22 @@ Project loadProject(const std::string& path) { } } +std::string nextExportPath(const std::string& previous) { + const std::filesystem::path path(previous); + std::string stem = path.stem().string(); + long count = 0; + const auto dash = stem.rfind('-'); + if (dash != std::string::npos && dash + 1 < stem.size() && stem.size() - dash <= 10 && + stem.find_first_not_of("0123456789", dash + 1) == std::string::npos) { + count = std::strtol(stem.c_str() + dash + 1, nullptr, 10); + stem.resize(dash); + } + std::filesystem::path next; + std::error_code error; + do { + next = path.parent_path() / (stem + "-" + std::to_string(++count) + path.extension().string()); + } while (std::filesystem::exists(next, error)); + return next.string(); +} + } // namespace effector diff --git a/src/core/project.hpp b/src/core/project.hpp index a140ef2..729bf53 100644 --- a/src/core/project.hpp +++ b/src/core/project.hpp @@ -54,4 +54,9 @@ Project projectFromJson(const std::string& text, const std::string& directory = void saveProject(const std::string& path, const Project& project); Project loadProject(const std::string& path); +// The name to offer for the next export after `previous`: its stem with the +// trailing `-N` counted on (`city.png` → `city-1.png`, `city-4.png` → +// `city-5.png`), skipping any that already exist in its folder. +std::string nextExportPath(const std::string& previous); + } // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index b6ba65a..68f68e8 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -2476,6 +2476,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat seedEntry_.set_text(std::to_string(seed_)); projectPath_ = path; + lastExport_.clear(); updateTitle(); clearError(); suspendRender_ = false; @@ -2801,8 +2802,27 @@ void EffectorWindow::onSave() { auto dialog = Gtk::FileDialog::create(); dialog->set_title(video ? "Save video" : "Save image"); - dialog->set_initial_name("effector-" + chainSlug(stages_, video) + "-" + std::to_string(seed_) + - (video ? ".mp4" : ".png")); + // The project's own folder when it has one, else where the last save went. + std::filesystem::path folder; + if (!projectPath_.empty()) { + folder = std::filesystem::path(projectPath_).parent_path(); + } else if (!lastExport_.empty()) { + folder = std::filesystem::path(lastExport_).parent_path(); + } + if (!folder.empty()) dialog->set_initial_folder(Gio::File::create_for_path(folder.string())); + // The last save's name counted on, so saving again never offers to + // overwrite it; its extension only when it is the same kind of file. + std::string name; + if (lastExport_.empty()) { + name = "effector-" + chainSlug(stages_, video) + "-" + std::to_string(seed_) + + (video ? ".mp4" : ".png"); + } else { + std::filesystem::path last(lastExport_); + if (isVideoPath(last.string()) != video) last.replace_extension(video ? ".mp4" : ".png"); + if (!folder.empty()) last = folder / last.filename(); + name = std::filesystem::path(nextExportPath(last.string())).filename().string(); + } + dialog->set_initial_name(name); dialog->save(*this, [this, dialog](const Glib::RefPtr& result) { onSaveChosen(result, dialog); }); @@ -2838,6 +2858,7 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr& result, return; } clearError(); + lastExport_ = path.string(); status_.set_text("Saved " + path.string()); status_.set_tooltip_text("Saved " + path.string()); } @@ -2956,6 +2977,7 @@ void EffectorWindow::onExportFinished() { return; } clearError(); + lastExport_ = exportPath_; status_.set_text("Saved " + exportPath_); status_.set_tooltip_text("Saved " + exportPath_); } diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 5bc0236..e99226c 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -206,6 +206,9 @@ private: Image result_; std::uint64_t seed_ = 0; std::string projectPath_; // empty until the project has been saved once + // The last image or video saved from this project, which the next Save + // offers again with its number counted on; empty until one is saved. + std::string lastExport_; Glib::RefPtr recentMenu_; sigc::connection recentChanged_; // the recent list outlives the window bool suspendRender_ = true; diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index a71fcad..f600848 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -1281,6 +1281,21 @@ int main() { { const auto dir = std::filesystem::temp_directory_path() / "effector_test_project"; std::filesystem::create_directories(dir); + { + const auto base = (dir / "city.png").string(); + std::filesystem::remove(dir / "city-1.png"); + std::filesystem::remove(dir / "city-2.png"); + check(nextExportPath(base) == (dir / "city-1.png").string(), + "the next export after a plain name counts from 1"); + check(nextExportPath((dir / "city-1.png").string()) == (dir / "city-2.png").string(), + "the next export counts on from the last number"); + std::ofstream((dir / "city-1.png").string()) << "x"; + check(nextExportPath(base) == (dir / "city-2.png").string(), + "the next export skips a name already on disk"); + std::filesystem::remove(dir / "city-1.png"); + check(nextExportPath((dir / "a-b.mp4").string()) == (dir / "a-b-1.mp4").string(), + "a dash before letters is not a count"); + } const auto source = dir / "source.png"; gradient(64, 48, 7).save(source.string());