A canvas side was cast to int with no range check, so a number that did not fit came back as one that did: `-s 4294968296x600` rendered a perfectly plausible 1000x600, and a hand-edited project file could ask for a width the reader turned into something else (or, for a double out of range, into undefined behaviour). Both front-ends now check the side before the cast, against one shared bound in chain.hpp — the 1..20000 the GUI spin buttons already offered, so the help text, the CLI error, the project reader and the interface all move together. The tests gain a `canvas size` section covering the round trip at both ends of the range and the refusals on either side of it, through the project reader and through `-s`; the test binary picks up the CLI parser to do it, which stays GUI-free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1179 lines
41 KiB
C++
1179 lines
41 KiB
C++
#include "gui/window.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <chrono>
|
||
#include <filesystem>
|
||
#include <sstream>
|
||
|
||
#include "core/chain.hpp"
|
||
#include "core/project.hpp"
|
||
#include "core/registry.hpp"
|
||
#include "core/rng.hpp"
|
||
|
||
namespace effector {
|
||
namespace {
|
||
|
||
const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp",
|
||
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
|
||
"*.JPG", "*.JPEG", "*.WEBP"};
|
||
|
||
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
|
||
auto filter = Gtk::FileFilter::create();
|
||
filter->set_name("Effector projects");
|
||
filter->add_pattern("*.json");
|
||
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
|
||
filters->append(filter);
|
||
return filters;
|
||
}
|
||
|
||
Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
|
||
auto bytes = Glib::Bytes::create(image.data(), image.byteSize());
|
||
return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8,
|
||
bytes, image.stride());
|
||
}
|
||
|
||
Gtk::Label* sectionLabel(const char* text) {
|
||
auto* label = Gtk::make_managed<Gtk::Label>(text);
|
||
label->set_xalign(0.0F);
|
||
label->add_css_class("heading");
|
||
return label;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
EffectorWindow::EffectorWindow(const Options& initial) {
|
||
set_title("Effector");
|
||
set_default_size(1180, 760);
|
||
|
||
effects_ = effects();
|
||
stages_.push_back({effects_.front(), effects_.front()->defaults()});
|
||
seed_ = Rng::randomSeed();
|
||
|
||
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
||
loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress));
|
||
loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished));
|
||
|
||
buildUi();
|
||
applyInitial(initial);
|
||
|
||
suspendRender_ = false;
|
||
if (loading_) {
|
||
placeholder_.set_text(missingText());
|
||
} else if (canRender()) {
|
||
startRender();
|
||
} else {
|
||
showPlaceholder();
|
||
}
|
||
}
|
||
|
||
EffectorWindow::~EffectorWindow() {
|
||
debounce_.disconnect();
|
||
loadWindowDelay_.disconnect();
|
||
loadCancel_.store(true, std::memory_order_relaxed);
|
||
if (loader_.joinable()) loader_.join();
|
||
if (worker_.joinable()) worker_.join();
|
||
}
|
||
|
||
// ------------------------------------------------------------------- UI ---
|
||
|
||
void EffectorWindow::buildUi() {
|
||
auto* header = Gtk::make_managed<Gtk::HeaderBar>();
|
||
set_titlebar(*header);
|
||
|
||
menuButton_.set_icon_name("open-menu-symbolic");
|
||
menuButton_.set_tooltip_text("Project menu");
|
||
menuButton_.set_popover(static_cast<Gtk::PopoverMenu&>(buildMainMenu()));
|
||
header->pack_end(menuButton_);
|
||
|
||
saveButton_.set_sensitive(false);
|
||
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
|
||
header->pack_end(saveButton_);
|
||
|
||
recreateButton_.set_icon_name("view-refresh-symbolic");
|
||
recreateButton_.set_tooltip_text("Recreate with a new seed");
|
||
recreateButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRecreate));
|
||
header->pack_end(recreateButton_);
|
||
|
||
auto* root = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
|
||
set_child(*root);
|
||
|
||
banner_.set_xalign(0.0F);
|
||
banner_.set_wrap(true);
|
||
banner_.set_visible(false);
|
||
banner_.add_css_class("error");
|
||
banner_.set_margin(8);
|
||
root->append(banner_);
|
||
|
||
auto* paned = Gtk::make_managed<Gtk::Paned>(Gtk::Orientation::HORIZONTAL);
|
||
paned->set_vexpand(true);
|
||
paned->set_position(330);
|
||
paned->set_shrink_start_child(false);
|
||
paned->set_start_child(buildSidebar());
|
||
paned->set_end_child(buildPreview());
|
||
root->append(*paned);
|
||
}
|
||
|
||
// The chain is what the interface is about, so it comes first and the params
|
||
// of the selected stage sit right below it. The sources are a prerequisite,
|
||
// not the subject: they live at the bottom in a collapsed expander.
|
||
Gtk::Widget& EffectorWindow::buildSidebar() {
|
||
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||
sidebar->set_margin(12);
|
||
|
||
sidebar->append(buildChainSection());
|
||
|
||
paramHeading_.set_xalign(0.0F);
|
||
paramHeading_.add_css_class("heading");
|
||
sidebar->append(paramHeading_);
|
||
paramGrid_.set_column_spacing(8);
|
||
paramGrid_.set_row_spacing(6);
|
||
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||
paramScroller->set_child(paramGrid_);
|
||
paramScroller->set_vexpand(true);
|
||
sidebar->append(*paramScroller);
|
||
rebuildChainRows();
|
||
buildParamWidgets();
|
||
|
||
sidebar->append(*Gtk::make_managed<Gtk::Separator>(Gtk::Orientation::HORIZONTAL));
|
||
|
||
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
|
||
sourceList_.add_css_class("boxed-list");
|
||
auto* empty = Gtk::make_managed<Gtk::Label>("No images yet");
|
||
empty->set_margin_top(12);
|
||
empty->set_margin_bottom(12);
|
||
sourceList_.set_placeholder(*empty);
|
||
|
||
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||
scroller->set_child(sourceList_);
|
||
scroller->set_size_request(-1, 140);
|
||
scroller->set_margin_top(6);
|
||
|
||
auto* sourcesBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
||
sourcesBox->append(*scroller);
|
||
|
||
// Same shape as "Add effect": the icon and the text are put together by
|
||
// hand because set_label() and set_icon_name() replace one another.
|
||
auto* addBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||
addBox->append(*Gtk::make_managed<Gtk::Image>(
|
||
Gio::ThemedIcon::create("list-add-symbolic")));
|
||
addBox->append(*Gtk::make_managed<Gtk::Label>("Add images"));
|
||
auto* addButton = Gtk::make_managed<Gtk::Button>();
|
||
addButton->set_child(*addBox);
|
||
addButton->set_halign(Gtk::Align::START);
|
||
addButton->set_tooltip_text("Add source images");
|
||
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
|
||
sourcesBox->append(*addButton);
|
||
addButton_ = addButton;
|
||
|
||
sourcesExpander_.set_child(*sourcesBox);
|
||
sourcesExpander_.set_expanded(false);
|
||
updateSourcesLabel();
|
||
sidebar->append(sourcesExpander_);
|
||
|
||
sidebar->append(*sectionLabel("Canvas"));
|
||
auto* grid = Gtk::make_managed<Gtk::Grid>();
|
||
grid->set_column_spacing(8);
|
||
grid->set_row_spacing(6);
|
||
widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||
widthSpin_.set_increments(1, 100);
|
||
widthSpin_.set_value(1920);
|
||
heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||
heightSpin_.set_increments(1, 100);
|
||
heightSpin_.set_value(1080);
|
||
for (auto* spin : {&widthSpin_, &heightSpin_}) {
|
||
spin->set_hexpand(true);
|
||
spin->signal_value_changed().connect(sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||
}
|
||
auto* widthLabel = Gtk::make_managed<Gtk::Label>("Width");
|
||
widthLabel->set_xalign(0.0F);
|
||
auto* heightLabel = Gtk::make_managed<Gtk::Label>("Height");
|
||
heightLabel->set_xalign(0.0F);
|
||
grid->attach(*widthLabel, 0, 0);
|
||
grid->attach(widthSpin_, 1, 0);
|
||
grid->attach(*heightLabel, 0, 1);
|
||
grid->attach(heightSpin_, 1, 1);
|
||
sidebar->append(*grid);
|
||
|
||
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
|
||
seedLabel->set_xalign(0.0F);
|
||
seedBox->append(*seedLabel);
|
||
seedEntry_.set_hexpand(true);
|
||
seedEntry_.set_text(std::to_string(seed_));
|
||
seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered));
|
||
seedBox->append(seedEntry_);
|
||
sidebar->append(*seedBox);
|
||
|
||
return *sidebar;
|
||
}
|
||
|
||
// The expander is collapsed by default, so its label carries the count that
|
||
// used to be plain to see in the list.
|
||
void EffectorWindow::updateSourcesLabel() {
|
||
int count = 0;
|
||
while (sourceList_.get_row_at_index(count) != nullptr) ++count;
|
||
sourcesExpander_.set_label(count == 0 ? "Source images"
|
||
: "Source images (" + std::to_string(count) + ")");
|
||
}
|
||
|
||
Gtk::Widget& EffectorWindow::buildPreview() {
|
||
picture_.set_can_shrink(true);
|
||
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
|
||
|
||
placeholder_.set_text("Add source images to start.");
|
||
placeholder_.set_wrap(true);
|
||
placeholder_.add_css_class("dim-label");
|
||
|
||
previewStack_.add(placeholder_, "placeholder");
|
||
previewStack_.add(picture_, "picture");
|
||
previewStack_.set_visible_child("placeholder");
|
||
|
||
spinner_.set_halign(Gtk::Align::CENTER);
|
||
spinner_.set_valign(Gtk::Align::CENTER);
|
||
|
||
auto* overlay = Gtk::make_managed<Gtk::Overlay>();
|
||
overlay->set_hexpand(true);
|
||
overlay->set_vexpand(true);
|
||
overlay->set_child(previewStack_);
|
||
overlay->add_overlay(spinner_);
|
||
|
||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
|
||
box->append(*overlay);
|
||
status_.set_margin_top(6);
|
||
status_.set_margin_bottom(8);
|
||
status_.add_css_class("dim-label");
|
||
box->append(status_);
|
||
return *box;
|
||
}
|
||
|
||
void EffectorWindow::buildParamWidgets() {
|
||
while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child);
|
||
paramWidgets_.clear();
|
||
ownedWidgets_.clear();
|
||
|
||
if (stages_.empty()) {
|
||
paramHeading_.set_text("No effects");
|
||
return;
|
||
}
|
||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
|
||
" settings");
|
||
|
||
int row = 0;
|
||
for (const auto& spec : stage.effect->params()) {
|
||
auto label = std::make_unique<Gtk::Label>(spec.label);
|
||
label->set_xalign(0.0F);
|
||
if (!spec.help.empty()) label->set_tooltip_text(spec.help);
|
||
|
||
std::unique_ptr<Gtk::Widget> widget;
|
||
switch (spec.type) {
|
||
case ParamSpec::Type::Bool: {
|
||
auto toggle = std::make_unique<Gtk::Switch>();
|
||
toggle->set_halign(Gtk::Align::START);
|
||
toggle->set_active(paramBool(stage.values, spec.key, spec.defaultValue != 0.0));
|
||
toggle->property_active().signal_changed().connect(
|
||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||
widget = std::move(toggle);
|
||
break;
|
||
}
|
||
case ParamSpec::Type::Int:
|
||
case ParamSpec::Type::Double: {
|
||
auto spin = std::make_unique<Gtk::SpinButton>();
|
||
spin->set_range(spec.minimum, spec.maximum);
|
||
spin->set_increments(spec.step, spec.step * 10);
|
||
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
|
||
spin->set_value(spec.type == ParamSpec::Type::Int
|
||
? static_cast<double>(paramInt(stage.values, spec.key,
|
||
static_cast<long>(spec.defaultValue)))
|
||
: paramDouble(stage.values, spec.key, spec.defaultValue));
|
||
spin->signal_value_changed().connect(
|
||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||
widget = std::move(spin);
|
||
break;
|
||
}
|
||
case ParamSpec::Type::Choice: {
|
||
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
||
auto dropdown = std::make_unique<Gtk::DropDown>();
|
||
dropdown->set_model(Gtk::StringList::create(choices));
|
||
const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice);
|
||
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
|
||
if (spec.choices[i] == current) dropdown->set_selected(i);
|
||
}
|
||
dropdown->property_selected().signal_changed().connect(
|
||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||
widget = std::move(dropdown);
|
||
break;
|
||
}
|
||
}
|
||
|
||
widget->set_hexpand(true);
|
||
paramGrid_.attach(*label, 0, row);
|
||
paramGrid_.attach(*widget, 1, row);
|
||
paramWidgets_.push_back({spec, widget.get()});
|
||
ownedWidgets_.push_back(std::move(label));
|
||
ownedWidgets_.push_back(std::move(widget));
|
||
++row;
|
||
}
|
||
}
|
||
|
||
ParamValues EffectorWindow::paramValues() const {
|
||
if (stages_.empty()) return {};
|
||
ParamValues values = stages_[static_cast<std::size_t>(selectedStage_)].values;
|
||
for (const auto& entry : paramWidgets_) {
|
||
switch (entry.spec.type) {
|
||
case ParamSpec::Type::Bool:
|
||
values[entry.spec.key] = static_cast<Gtk::Switch*>(entry.widget)->get_active();
|
||
break;
|
||
case ParamSpec::Type::Int:
|
||
values[entry.spec.key] =
|
||
static_cast<long>(static_cast<Gtk::SpinButton*>(entry.widget)->get_value());
|
||
break;
|
||
case ParamSpec::Type::Double:
|
||
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
|
||
break;
|
||
case ParamSpec::Type::Choice: {
|
||
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
|
||
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return values;
|
||
}
|
||
|
||
// The widgets only ever show one stage, so their values are written back
|
||
// whenever they change; the other stages keep the settings they were given.
|
||
void EffectorWindow::storeParams() {
|
||
if (stages_.empty()) return;
|
||
stages_[static_cast<std::size_t>(selectedStage_)].values = paramValues();
|
||
}
|
||
|
||
void EffectorWindow::applyInitial(const Options& initial) {
|
||
// `-e scanlines -e blur` starts the interface with that cascade already set
|
||
// up, flags included.
|
||
auto stages = buildChain(initial);
|
||
if (!stages.empty()) stages_ = std::move(stages);
|
||
selectedStage_ = 0;
|
||
rebuildChainRows();
|
||
buildParamWidgets();
|
||
|
||
widthSpin_.set_value(initial.width);
|
||
heightSpin_.set_value(initial.height);
|
||
if (initial.seed) {
|
||
seed_ = *initial.seed;
|
||
seedEntry_.set_text(std::to_string(seed_));
|
||
}
|
||
if (!initial.images.empty()) addPaths(initial.images);
|
||
}
|
||
|
||
// -------------------------------------------------------------- sources ---
|
||
|
||
// Replaces the source snapshot, moving the images across when nothing else
|
||
// holds the old list (the common case) and copying only if a render is reading it.
|
||
template <typename Mutate>
|
||
void EffectorWindow::mutateSources(Mutate&& mutate) {
|
||
auto next = std::make_shared<SourceList>();
|
||
if (sources_.use_count() == 1) {
|
||
*next = std::move(const_cast<SourceList&>(*sources_));
|
||
} else {
|
||
*next = *sources_;
|
||
}
|
||
mutate(*next);
|
||
sources_ = std::move(next);
|
||
}
|
||
|
||
void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
|
||
if (paths.empty()) return;
|
||
if (loading_) { // a second selection arrived mid-batch; take it afterwards
|
||
loadQueue_.insert(loadQueue_.end(), paths.begin(), paths.end());
|
||
return;
|
||
}
|
||
|
||
loading_ = true;
|
||
loadCancel_.store(false, std::memory_order_relaxed);
|
||
loadDone_.store(0, std::memory_order_relaxed);
|
||
loadTotal_ = paths.size();
|
||
{
|
||
std::lock_guard<std::mutex> lock(loadMutex_);
|
||
loadCurrent_.clear();
|
||
}
|
||
if (addButton_ != nullptr) addButton_->set_sensitive(false);
|
||
|
||
// A couple of small files decode faster than a window can be drawn, so the
|
||
// progress window only appears if the batch is still going after a moment.
|
||
loadWindowDelay_.disconnect();
|
||
loadWindowDelay_ = Glib::signal_timeout().connect(
|
||
[this] {
|
||
showLoadWindow();
|
||
return false;
|
||
},
|
||
250);
|
||
|
||
loader_ = std::thread([this, paths] {
|
||
LoadProgress progress;
|
||
progress.cancel = &loadCancel_;
|
||
progress.onFile = [this](std::size_t done, std::size_t, const std::string& path) {
|
||
{
|
||
std::lock_guard<std::mutex> lock(loadMutex_);
|
||
loadCurrent_ = path;
|
||
}
|
||
loadDone_.store(done, std::memory_order_relaxed);
|
||
loadProgress_.emit();
|
||
};
|
||
|
||
std::vector<LoadFailure> failures;
|
||
auto images = loadImages(paths, failures, &progress);
|
||
{
|
||
std::lock_guard<std::mutex> lock(loadMutex_);
|
||
loadedImages_ = std::move(images);
|
||
loadFailures_ = std::move(failures);
|
||
}
|
||
loadFinished_.emit();
|
||
});
|
||
}
|
||
|
||
void EffectorWindow::onLoadProgress() {
|
||
const std::size_t done = loadDone_.load(std::memory_order_relaxed);
|
||
std::string current;
|
||
{
|
||
std::lock_guard<std::mutex> lock(loadMutex_);
|
||
current = loadCurrent_;
|
||
}
|
||
if (loadBar_ != nullptr && loadTotal_ > 0) {
|
||
loadBar_->set_fraction(static_cast<double>(done) / static_cast<double>(loadTotal_));
|
||
loadBar_->set_text(std::to_string(done) + " of " + std::to_string(loadTotal_));
|
||
}
|
||
if (loadFileLabel_ != nullptr && !current.empty()) {
|
||
loadFileLabel_->set_text(std::filesystem::path(current).filename().string());
|
||
}
|
||
}
|
||
|
||
void EffectorWindow::onLoadFinished() {
|
||
if (loader_.joinable()) loader_.join();
|
||
loading_ = false;
|
||
loadWindowDelay_.disconnect();
|
||
closeLoadWindow();
|
||
if (addButton_ != nullptr) addButton_->set_sensitive(true);
|
||
|
||
std::vector<Image> images;
|
||
std::vector<LoadFailure> failures;
|
||
{
|
||
std::lock_guard<std::mutex> lock(loadMutex_);
|
||
images = std::move(loadedImages_);
|
||
failures = std::move(loadFailures_);
|
||
loadedImages_.clear();
|
||
loadFailures_.clear();
|
||
}
|
||
|
||
if (loadCancel_.load(std::memory_order_relaxed)) {
|
||
// A cancelled batch is incomplete, so none of it is taken.
|
||
loadQueue_.clear();
|
||
dropLoadedImages_ = false;
|
||
showError("Loading cancelled.");
|
||
return;
|
||
}
|
||
|
||
if (dropLoadedImages_) {
|
||
// A project was opened while this batch was decoding: its images belong
|
||
// to the previous source list, and the project's own are queued behind.
|
||
dropLoadedImages_ = false;
|
||
images.clear();
|
||
failures.clear();
|
||
}
|
||
|
||
for (const auto& image : images) appendSourceRow(image.path());
|
||
mutateSources([&images](SourceList& list) {
|
||
for (auto& image : images) list.push_back(std::move(image));
|
||
});
|
||
|
||
if (!failures.empty()) {
|
||
std::string names;
|
||
for (const auto& failure : failures) {
|
||
if (!names.empty()) names += ", ";
|
||
names += std::filesystem::path(failure.path).filename().string();
|
||
}
|
||
showError("Could not read: " + names);
|
||
} else if (!images.empty()) {
|
||
clearError();
|
||
}
|
||
requestRender();
|
||
|
||
if (!loadQueue_.empty()) {
|
||
auto queued = std::move(loadQueue_);
|
||
loadQueue_.clear();
|
||
addPaths(queued);
|
||
}
|
||
}
|
||
|
||
void EffectorWindow::showLoadWindow() {
|
||
if (!loading_ || loadWindow_) return;
|
||
|
||
loadWindow_ = std::make_unique<Gtk::Window>();
|
||
loadWindow_->set_title("Reading images");
|
||
loadWindow_->set_transient_for(*this);
|
||
loadWindow_->set_modal(true);
|
||
loadWindow_->set_resizable(false);
|
||
loadWindow_->set_default_size(420, -1);
|
||
|
||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||
box->set_margin(18);
|
||
|
||
auto* heading = Gtk::make_managed<Gtk::Label>("Reading source images…");
|
||
heading->set_xalign(0.0F);
|
||
heading->add_css_class("heading");
|
||
box->append(*heading);
|
||
|
||
auto* bar = Gtk::make_managed<Gtk::ProgressBar>();
|
||
bar->set_show_text(true);
|
||
bar->set_text("0 of " + std::to_string(loadTotal_));
|
||
box->append(*bar);
|
||
loadBar_ = bar;
|
||
|
||
auto* file = Gtk::make_managed<Gtk::Label>("");
|
||
file->set_xalign(0.0F);
|
||
file->set_ellipsize(Pango::EllipsizeMode::MIDDLE);
|
||
file->add_css_class("dim-label");
|
||
box->append(*file);
|
||
loadFileLabel_ = file;
|
||
|
||
auto* cancel = Gtk::make_managed<Gtk::Button>("Cancel");
|
||
cancel->set_halign(Gtk::Align::END);
|
||
cancel->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::cancelLoad));
|
||
box->append(*cancel);
|
||
|
||
// Closing the window means the same thing as pressing Cancel, but the
|
||
// window stays up until the decoding threads have actually stopped.
|
||
loadWindow_->signal_close_request().connect(
|
||
[this] {
|
||
cancelLoad();
|
||
return true;
|
||
},
|
||
false);
|
||
|
||
loadWindow_->set_child(*box);
|
||
onLoadProgress(); // show whatever finished while the window was pending
|
||
loadWindow_->present();
|
||
}
|
||
|
||
void EffectorWindow::closeLoadWindow() {
|
||
loadBar_ = nullptr;
|
||
loadFileLabel_ = nullptr;
|
||
if (!loadWindow_) return;
|
||
loadWindow_->set_hide_on_close(false);
|
||
loadWindow_->hide();
|
||
loadWindow_.reset();
|
||
}
|
||
|
||
void EffectorWindow::cancelLoad() {
|
||
if (!loading_) return;
|
||
loadCancel_.store(true, std::memory_order_relaxed);
|
||
if (loadBar_ != nullptr) loadBar_->set_text("Cancelling…");
|
||
if (loadFileLabel_ != nullptr) loadFileLabel_->set_text("");
|
||
}
|
||
|
||
void EffectorWindow::appendSourceRow(const std::string& path) {
|
||
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
|
||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||
box->set_margin_top(4);
|
||
box->set_margin_bottom(4);
|
||
box->set_margin_start(8);
|
||
box->set_margin_end(4);
|
||
|
||
auto* label = Gtk::make_managed<Gtk::Label>(std::filesystem::path(path).filename().string());
|
||
label->set_xalign(0.0F);
|
||
label->set_hexpand(true);
|
||
label->set_ellipsize(Pango::EllipsizeMode::END);
|
||
label->set_tooltip_text(path);
|
||
box->append(*label);
|
||
|
||
auto* remove = Gtk::make_managed<Gtk::Button>();
|
||
remove->set_icon_name("window-close-symbolic");
|
||
remove->add_css_class("flat");
|
||
remove->signal_clicked().connect([this, row] { removeSource(row); });
|
||
box->append(*remove);
|
||
|
||
row->set_child(*box);
|
||
sourceList_.append(*row);
|
||
updateSourcesLabel();
|
||
}
|
||
|
||
void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
|
||
const int index = row->get_index();
|
||
mutateSources([index](SourceList& list) {
|
||
if (index >= 0 && static_cast<std::size_t>(index) < list.size()) {
|
||
list.erase(list.begin() + index);
|
||
}
|
||
});
|
||
sourceList_.remove(*row);
|
||
updateSourcesLabel();
|
||
requestRender();
|
||
}
|
||
|
||
void EffectorWindow::onAddImages() {
|
||
auto dialog = Gtk::FileDialog::create();
|
||
dialog->set_title("Select source images");
|
||
|
||
auto filter = Gtk::FileFilter::create();
|
||
filter->set_name("Images");
|
||
for (const char* pattern : kImagePatterns) filter->add_pattern(pattern);
|
||
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
|
||
filters->append(filter);
|
||
dialog->set_filters(filters);
|
||
dialog->set_default_filter(filter);
|
||
|
||
dialog->open_multiple(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||
onImagesChosen(result, dialog);
|
||
});
|
||
}
|
||
|
||
void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
|
||
std::vector<std::string> paths;
|
||
try {
|
||
for (const auto& file : dialog->open_multiple_finish(result)) {
|
||
if (file) paths.push_back(file->get_path());
|
||
}
|
||
} catch (const Glib::Error&) {
|
||
return; // cancelled
|
||
}
|
||
if (!paths.empty()) addPaths(paths);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- chain ---
|
||
|
||
Gtk::Widget& EffectorWindow::buildChainSection() {
|
||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
||
box->append(*sectionLabel("Effect chain"));
|
||
|
||
chainList_.add_css_class("boxed-list");
|
||
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
|
||
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
|
||
empty->set_margin_top(12);
|
||
empty->set_margin_bottom(12);
|
||
chainList_.set_placeholder(*empty);
|
||
chainList_.signal_row_selected().connect(
|
||
[this](Gtk::ListBoxRow*) { onStageSelected(); });
|
||
|
||
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||
scroller->set_child(chainList_);
|
||
scroller->set_size_request(-1, 150);
|
||
box->append(*scroller);
|
||
|
||
// set_label() and set_icon_name() replace one another, so the icon and the
|
||
// text are put together by hand.
|
||
auto* addBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||
addBox->append(*Gtk::make_managed<Gtk::Image>(
|
||
Gio::ThemedIcon::create("list-add-symbolic")));
|
||
addBox->append(*Gtk::make_managed<Gtk::Label>("Add effect"));
|
||
addEffectButton_.set_child(*addBox);
|
||
addEffectButton_.set_always_show_arrow(true);
|
||
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
|
||
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
|
||
addEffectButton_.set_halign(Gtk::Align::START);
|
||
box->append(addEffectButton_);
|
||
|
||
return *box;
|
||
}
|
||
|
||
// One entry per registered effect, so a new effect shows up here without this
|
||
// file knowing anything about it.
|
||
Gtk::Widget& EffectorWindow::buildAddMenu() {
|
||
auto* popover = Gtk::make_managed<Gtk::Popover>();
|
||
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
|
||
|
||
for (const auto& effect : effects_) {
|
||
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
|
||
item->add_css_class("flat");
|
||
item->set_tooltip_text(effect->description());
|
||
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
|
||
item->signal_clicked().connect([this, effect] {
|
||
addEffectButton_.popdown();
|
||
appendStage(effect);
|
||
});
|
||
list->append(*item);
|
||
}
|
||
|
||
popover->set_child(*list);
|
||
return *popover;
|
||
}
|
||
|
||
void EffectorWindow::appendStage(const EffectPtr& effect) {
|
||
stages_.push_back({effect, effect->defaults()});
|
||
selectedStage_ = static_cast<int>(stages_.size()) - 1;
|
||
rebuildChainRows();
|
||
buildParamWidgets();
|
||
requestRender();
|
||
}
|
||
|
||
void EffectorWindow::removeStage(int index) {
|
||
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
|
||
stages_.erase(stages_.begin() + index);
|
||
selectedStage_ = std::min(selectedStage_, static_cast<int>(stages_.size()) - 1);
|
||
if (selectedStage_ < 0) selectedStage_ = 0;
|
||
rebuildChainRows();
|
||
buildParamWidgets();
|
||
|
||
requestRender();
|
||
}
|
||
|
||
void EffectorWindow::moveStage(int index, int delta) {
|
||
const int target = index + delta;
|
||
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
|
||
if (target < 0 || static_cast<std::size_t>(target) >= stages_.size()) return;
|
||
std::swap(stages_[static_cast<std::size_t>(index)], stages_[static_cast<std::size_t>(target)]);
|
||
selectedStage_ = target;
|
||
rebuildChainRows();
|
||
buildParamWidgets();
|
||
requestRender();
|
||
}
|
||
|
||
void EffectorWindow::rebuildChainRows() {
|
||
// Rebuilding drops and re-adds the selection, which would otherwise look
|
||
// like the user picking a different stage.
|
||
suspendStageSelect_ = true;
|
||
// Only the rows: the placeholder is a child of the list box as well, and
|
||
// removing it would leave the empty list blank.
|
||
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
|
||
|
||
for (std::size_t i = 0; i < stages_.size(); ++i) {
|
||
const int index = static_cast<int>(i);
|
||
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
|
||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
|
||
box->set_margin_top(4);
|
||
box->set_margin_bottom(4);
|
||
box->set_margin_start(8);
|
||
box->set_margin_end(4);
|
||
|
||
auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " +
|
||
stages_[i].effect->title());
|
||
label->set_xalign(0.0F);
|
||
label->set_hexpand(true);
|
||
label->set_ellipsize(Pango::EllipsizeMode::END);
|
||
label->set_tooltip_text(index == 0 ? "Applied to the source images"
|
||
: "Applied to the output of the stage above");
|
||
box->append(*label);
|
||
|
||
struct Action {
|
||
const char* icon;
|
||
const char* tip;
|
||
int delta; // 0 means remove
|
||
};
|
||
for (const Action& action : {Action{"go-up-symbolic", "Move up", -1},
|
||
Action{"go-down-symbolic", "Move down", 1},
|
||
Action{"window-close-symbolic", "Remove", 0}}) {
|
||
auto* button = Gtk::make_managed<Gtk::Button>();
|
||
button->set_icon_name(action.icon);
|
||
button->set_tooltip_text(action.tip);
|
||
button->add_css_class("flat");
|
||
const int delta = action.delta;
|
||
button->signal_clicked().connect([this, index, delta] {
|
||
if (delta == 0) {
|
||
removeStage(index);
|
||
} else {
|
||
moveStage(index, delta);
|
||
}
|
||
});
|
||
if (delta == -1) button->set_sensitive(index > 0);
|
||
if (delta == 1) button->set_sensitive(static_cast<std::size_t>(index) + 1 < stages_.size());
|
||
box->append(*button);
|
||
}
|
||
|
||
row->set_child(*box);
|
||
chainList_.append(*row);
|
||
}
|
||
|
||
selectStage(selectedStage_);
|
||
suspendStageSelect_ = false;
|
||
}
|
||
|
||
void EffectorWindow::selectStage(int index) {
|
||
if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row);
|
||
}
|
||
|
||
void EffectorWindow::onStageSelected() {
|
||
if (suspendStageSelect_) return;
|
||
auto* row = chainList_.get_selected_row();
|
||
if (row == nullptr) return;
|
||
if (row->get_index() == selectedStage_) return;
|
||
selectedStage_ = row->get_index();
|
||
buildParamWidgets();
|
||
}
|
||
|
||
// ------------------------------------------------------------ rendering ---
|
||
|
||
void EffectorWindow::onParamChanged() {
|
||
storeParams();
|
||
requestRender();
|
||
}
|
||
|
||
void EffectorWindow::onSeedEntered() {
|
||
try {
|
||
seed_ = std::stoull(seedEntry_.get_text());
|
||
} catch (const std::exception&) {
|
||
seedEntry_.set_text(std::to_string(seed_));
|
||
return;
|
||
}
|
||
requestRender();
|
||
}
|
||
|
||
void EffectorWindow::onRecreate() {
|
||
seed_ = Rng::randomSeed();
|
||
seedEntry_.set_text(std::to_string(seed_));
|
||
if (!canRender()) {
|
||
showError(missingText());
|
||
return;
|
||
}
|
||
requestRender();
|
||
}
|
||
|
||
// Whether the effect chain has everything it needs. Only the first stage is
|
||
// ever handed the sources, so an effect that does not want them (blur) renders
|
||
// happily with none loaded.
|
||
bool EffectorWindow::canRender() const {
|
||
if (stages_.empty()) return false;
|
||
return !sources_->empty() || !chainNeedsSources(stages_);
|
||
}
|
||
|
||
std::string EffectorWindow::missingText() const {
|
||
if (stages_.empty()) return "Add an effect to start.";
|
||
return "Add source images to start.";
|
||
}
|
||
|
||
void EffectorWindow::showPlaceholder() {
|
||
result_ = Image();
|
||
saveButton_.set_sensitive(false);
|
||
placeholder_.set_text(missingText());
|
||
previewStack_.set_visible_child("placeholder");
|
||
status_.set_text("");
|
||
}
|
||
|
||
void EffectorWindow::requestRender() {
|
||
if (suspendRender_) return;
|
||
if (!canRender()) {
|
||
showPlaceholder();
|
||
return;
|
||
}
|
||
// Spin buttons fire on every step while they are held down; coalesce those
|
||
// into one render instead of queueing a job per tick.
|
||
debounce_.disconnect();
|
||
debounce_ = Glib::signal_timeout().connect(
|
||
[this] {
|
||
startRender();
|
||
return false;
|
||
},
|
||
120);
|
||
}
|
||
|
||
void EffectorWindow::startRender() {
|
||
if (!canRender()) return;
|
||
if (rendering_) {
|
||
staleResult_ = true; // a fresh render follows this one
|
||
return;
|
||
}
|
||
rendering_ = true;
|
||
|
||
const auto sources = sources_;
|
||
const auto stages = stages_; // a snapshot, like the sources
|
||
const int width = static_cast<int>(widthSpin_.get_value());
|
||
const int height = static_cast<int>(heightSpin_.get_value());
|
||
const std::uint64_t seed = seed_;
|
||
|
||
spinner_.start();
|
||
recreateButton_.set_sensitive(false);
|
||
|
||
worker_ = std::thread([this, sources, stages, width, height, seed] {
|
||
Image image;
|
||
std::string error;
|
||
const auto start = std::chrono::steady_clock::now();
|
||
try {
|
||
Rng rng(seed);
|
||
image = renderChain(stages, *sources, width, height, rng);
|
||
} catch (const std::exception& exception) {
|
||
error = exception.what();
|
||
}
|
||
const auto millis = std::chrono::duration<double, std::milli>(
|
||
std::chrono::steady_clock::now() - start)
|
||
.count();
|
||
{
|
||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||
pendingImage_ = std::move(image);
|
||
pendingError_ = std::move(error);
|
||
pendingMillis_ = millis;
|
||
}
|
||
renderDone_.emit();
|
||
});
|
||
}
|
||
|
||
void EffectorWindow::onRenderFinished() {
|
||
if (worker_.joinable()) worker_.join();
|
||
rendering_ = false;
|
||
|
||
Image image;
|
||
std::string error;
|
||
double millis = 0.0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(resultMutex_);
|
||
image = std::move(pendingImage_);
|
||
error = pendingError_;
|
||
millis = pendingMillis_;
|
||
}
|
||
|
||
if (staleResult_) { // settings changed while this one was running
|
||
staleResult_ = false;
|
||
startRender();
|
||
return;
|
||
}
|
||
|
||
spinner_.stop();
|
||
recreateButton_.set_sensitive(true);
|
||
|
||
if (!error.empty()) {
|
||
result_ = Image();
|
||
saveButton_.set_sensitive(false);
|
||
showError(error);
|
||
return;
|
||
}
|
||
|
||
clearError();
|
||
result_ = std::move(image);
|
||
picture_.set_paintable(toTexture(result_));
|
||
previewStack_.set_visible_child("picture");
|
||
saveButton_.set_sensitive(true);
|
||
|
||
std::ostringstream status;
|
||
status.setf(std::ios::fixed);
|
||
status.precision(1);
|
||
status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
|
||
if (!sources_->empty()) {
|
||
status << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
|
||
}
|
||
status << "seed " << seed_ << " · " << millis << " ms";
|
||
status_.set_text(status.str());
|
||
}
|
||
|
||
// -------------------------------------------------------------- project ---
|
||
|
||
// The project menu lives in the header bar, next to the buttons that act on
|
||
// the current render.
|
||
Gtk::Widget& EffectorWindow::buildMainMenu() {
|
||
add_action("new-project", sigc::mem_fun(*this, &EffectorWindow::onNewProject));
|
||
add_action("open-project", sigc::mem_fun(*this, &EffectorWindow::onOpenProject));
|
||
add_action("save-project", sigc::mem_fun(*this, &EffectorWindow::onSaveProject));
|
||
add_action("save-project-as", sigc::mem_fun(*this, &EffectorWindow::onSaveProjectAs));
|
||
|
||
auto menu = Gio::Menu::create();
|
||
auto open = Gio::Menu::create();
|
||
open->append("New Project", "win.new-project");
|
||
open->append("Open Project…", "win.open-project");
|
||
menu->append_section(open);
|
||
auto save = Gio::Menu::create();
|
||
save->append("Save Project", "win.save-project");
|
||
save->append("Save Project As…", "win.save-project-as");
|
||
menu->append_section(save);
|
||
|
||
// The shortcuts hang off the window rather than the application, which the
|
||
// window does not belong to yet while it is being built.
|
||
auto shortcuts = Gtk::ShortcutController::create();
|
||
shortcuts->set_scope(Gtk::ShortcutScope::GLOBAL);
|
||
const struct Accel {
|
||
const char* keys;
|
||
const char* action;
|
||
} accels[] = {{"<Control>n", "win.new-project"},
|
||
{"<Control>o", "win.open-project"},
|
||
{"<Control>s", "win.save-project"},
|
||
{"<Control><Shift>s", "win.save-project-as"}};
|
||
for (const Accel& accel : accels) {
|
||
shortcuts->add_shortcut(Gtk::Shortcut::create(
|
||
Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action)));
|
||
}
|
||
add_controller(shortcuts);
|
||
|
||
return *Gtk::make_managed<Gtk::PopoverMenu>(menu);
|
||
}
|
||
|
||
Project EffectorWindow::currentProject() const {
|
||
Project project;
|
||
for (const auto& image : *sources_) project.sources.push_back(image.path());
|
||
project.stages = stages_;
|
||
project.width = static_cast<int>(widthSpin_.get_value());
|
||
project.height = static_cast<int>(heightSpin_.get_value());
|
||
project.seed = seed_;
|
||
return project;
|
||
}
|
||
|
||
void EffectorWindow::applyProject(const Project& project, const std::string& path) {
|
||
// One render at the end rather than one per piece of the project.
|
||
suspendRender_ = true;
|
||
clearSources();
|
||
|
||
stages_ = project.stages;
|
||
selectedStage_ = 0;
|
||
rebuildChainRows();
|
||
buildParamWidgets();
|
||
|
||
widthSpin_.set_value(project.width);
|
||
heightSpin_.set_value(project.height);
|
||
seed_ = project.seed.value_or(Rng::randomSeed());
|
||
seedEntry_.set_text(std::to_string(seed_));
|
||
|
||
projectPath_ = path;
|
||
updateTitle();
|
||
clearError();
|
||
suspendRender_ = false;
|
||
|
||
if (project.sources.empty()) {
|
||
requestRender(); // a chain that needs no sources still has a picture
|
||
} else {
|
||
addPaths(project.sources); // renders once the images are decoded
|
||
}
|
||
}
|
||
|
||
void EffectorWindow::clearSources() {
|
||
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
|
||
updateSourcesLabel();
|
||
mutateSources([](SourceList& list) { list.clear(); });
|
||
loadQueue_.clear();
|
||
if (loading_) dropLoadedImages_ = true;
|
||
|
||
result_ = Image();
|
||
saveButton_.set_sensitive(false);
|
||
previewStack_.set_visible_child("placeholder");
|
||
}
|
||
|
||
void EffectorWindow::onNewProject() {
|
||
Project fresh;
|
||
fresh.stages.push_back({effects_.front(), effects_.front()->defaults()});
|
||
applyProject(fresh, "");
|
||
}
|
||
|
||
void EffectorWindow::onOpenProject() {
|
||
auto dialog = Gtk::FileDialog::create();
|
||
dialog->set_title("Open project");
|
||
dialog->set_filters(projectFilters());
|
||
dialog->open(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||
onProjectChosen(result, dialog);
|
||
});
|
||
}
|
||
|
||
void EffectorWindow::onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
|
||
std::string path;
|
||
try {
|
||
const auto file = dialog->open_finish(result);
|
||
if (!file) return;
|
||
path = file->get_path();
|
||
} catch (const Glib::Error&) {
|
||
return; // cancelled
|
||
}
|
||
|
||
try {
|
||
applyProject(loadProject(path), path);
|
||
} catch (const std::exception& error) {
|
||
showError(std::string("Could not open the project: ") + error.what());
|
||
}
|
||
}
|
||
|
||
void EffectorWindow::onSaveProject() {
|
||
if (projectPath_.empty()) {
|
||
onSaveProjectAs();
|
||
return;
|
||
}
|
||
writeProject(projectPath_);
|
||
}
|
||
|
||
void EffectorWindow::onSaveProjectAs() {
|
||
auto dialog = Gtk::FileDialog::create();
|
||
dialog->set_title("Save project");
|
||
dialog->set_filters(projectFilters());
|
||
dialog->set_initial_name(projectPath_.empty()
|
||
? "effector-" + chainSlug(stages_) + ".json"
|
||
: std::filesystem::path(projectPath_).filename().string());
|
||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||
onSaveProjectChosen(result, dialog);
|
||
});
|
||
}
|
||
|
||
void EffectorWindow::onSaveProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
|
||
std::filesystem::path path;
|
||
try {
|
||
const auto file = dialog->save_finish(result);
|
||
if (!file) return;
|
||
path = file->get_path();
|
||
} catch (const Glib::Error&) {
|
||
return; // cancelled
|
||
}
|
||
if (path.extension().empty()) path.replace_extension(".json");
|
||
writeProject(path.string());
|
||
}
|
||
|
||
void EffectorWindow::writeProject(const std::string& path) {
|
||
try {
|
||
saveProject(path, currentProject());
|
||
} catch (const std::exception& error) {
|
||
showError(std::string("Could not save the project: ") + error.what());
|
||
return;
|
||
}
|
||
clearError();
|
||
projectPath_ = path;
|
||
updateTitle();
|
||
status_.set_text("Saved " + path);
|
||
}
|
||
|
||
void EffectorWindow::updateTitle() {
|
||
if (projectPath_.empty()) {
|
||
set_title("Effector");
|
||
return;
|
||
}
|
||
set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector");
|
||
}
|
||
|
||
// --------------------------------------------------------------- saving ---
|
||
|
||
void EffectorWindow::onSave() {
|
||
if (result_.empty()) return;
|
||
|
||
auto dialog = Gtk::FileDialog::create();
|
||
dialog->set_title("Save image");
|
||
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + ".png");
|
||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||
onSaveChosen(result, dialog);
|
||
});
|
||
}
|
||
|
||
void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
|
||
std::filesystem::path path;
|
||
try {
|
||
const auto file = dialog->save_finish(result);
|
||
if (!file) return;
|
||
path = file->get_path();
|
||
} catch (const Glib::Error&) {
|
||
return; // cancelled
|
||
}
|
||
if (path.extension().empty()) path.replace_extension(".png");
|
||
|
||
try {
|
||
result_.save(path.string());
|
||
} catch (const std::exception& error) {
|
||
showError(std::string("Could not save: ") + error.what());
|
||
return;
|
||
}
|
||
clearError();
|
||
status_.set_text("Saved " + path.string());
|
||
}
|
||
|
||
// --------------------------------------------------------------- errors ---
|
||
|
||
void EffectorWindow::showError(const std::string& message) {
|
||
banner_.set_text(message);
|
||
banner_.set_visible(true);
|
||
}
|
||
|
||
void EffectorWindow::clearError() { banner_.set_visible(false); }
|
||
|
||
} // namespace effector
|