Effector: C++/CMake image effects app
Port of the Python prototype to C++20 with a CMake build: - effector_core: GUI-free static library (effects, image IO via gdk-pixbuf, declarative param specs, registry, portable seeded RNG, thread helper) - scanlines effect: plans strips from the seeded RNG, then copies pixels in parallel with memcpy and doubling horizontal tiling - gtkmm 4 interface: native file dialogs, widgets generated from the param specs, coalesced background renders, preview as a GDK memory texture - CLI: flags generated from the effect registry, headless rendering with no display required - ctest suite covering the RNG, registry, effect geometry and image round trip The Python prototype is kept under python-reference/.
This commit is contained in:
@@ -0,0 +1,603 @@
|
||||
#include "gui/window.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <sstream>
|
||||
|
||||
#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<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();
|
||||
effect_ = effects_.front();
|
||||
seed_ = Rng::randomSeed();
|
||||
|
||||
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
||||
|
||||
buildUi();
|
||||
applyInitial(initial);
|
||||
|
||||
suspendRender_ = false;
|
||||
if (!sources_->empty()) startRender();
|
||||
}
|
||||
|
||||
EffectorWindow::~EffectorWindow() {
|
||||
debounce_.disconnect();
|
||||
if (worker_.joinable()) worker_.join();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- UI ---
|
||||
|
||||
void EffectorWindow::buildUi() {
|
||||
auto* header = Gtk::make_managed<Gtk::HeaderBar>();
|
||||
set_titlebar(*header);
|
||||
|
||||
auto* addButton = Gtk::make_managed<Gtk::Button>();
|
||||
addButton->set_icon_name("list-add-symbolic");
|
||||
addButton->set_tooltip_text("Add images");
|
||||
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
|
||||
header->pack_start(*addButton);
|
||||
|
||||
std::vector<Glib::ustring> titles;
|
||||
for (const auto& effect : effects_) titles.push_back(effect->title());
|
||||
effectDropdown_.set_model(Gtk::StringList::create(titles));
|
||||
effectDropdown_.set_tooltip_text("Effect");
|
||||
effectDropdown_.property_selected().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onEffectChanged));
|
||||
header->pack_start(effectDropdown_);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Gtk::Widget& EffectorWindow::buildSidebar() {
|
||||
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||||
sidebar->set_margin(12);
|
||||
|
||||
sidebar->append(*sectionLabel("Source images"));
|
||||
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(18);
|
||||
empty->set_margin_bottom(18);
|
||||
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, 170);
|
||||
scroller->set_vexpand(true);
|
||||
sidebar->append(*scroller);
|
||||
|
||||
sidebar->append(*sectionLabel("Canvas"));
|
||||
auto* grid = Gtk::make_managed<Gtk::Grid>();
|
||||
grid->set_column_spacing(8);
|
||||
grid->set_row_spacing(6);
|
||||
widthSpin_.set_range(1, 20000);
|
||||
widthSpin_.set_increments(1, 100);
|
||||
widthSpin_.set_value(1920);
|
||||
heightSpin_.set_range(1, 20000);
|
||||
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);
|
||||
|
||||
sidebar->append(*sectionLabel("Effect settings"));
|
||||
paramGrid_.set_column_spacing(8);
|
||||
paramGrid_.set_row_spacing(6);
|
||||
sidebar->append(paramGrid_);
|
||||
buildParamWidgets();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
int row = 0;
|
||||
for (const auto& spec : 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(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.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));
|
||||
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
|
||||
if (spec.choices[i] == spec.defaultChoice) 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 {
|
||||
ParamValues values = effect_->defaults();
|
||||
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;
|
||||
}
|
||||
|
||||
void EffectorWindow::applyInitial(const Options& initial) {
|
||||
for (std::size_t i = 0; i < effects_.size(); ++i) {
|
||||
if (effects_[i]->name() == initial.effect) effectDropdown_.set_selected(i);
|
||||
}
|
||||
widthSpin_.set_value(initial.width);
|
||||
heightSpin_.set_value(initial.height);
|
||||
if (initial.seed) {
|
||||
seed_ = *initial.seed;
|
||||
seedEntry_.set_text(std::to_string(seed_));
|
||||
}
|
||||
for (const auto& entry : paramWidgets_) {
|
||||
const auto it = initial.params.find(entry.spec.key);
|
||||
if (it == initial.params.end()) continue;
|
||||
switch (entry.spec.type) {
|
||||
case ParamSpec::Type::Bool:
|
||||
static_cast<Gtk::Switch*>(entry.widget)
|
||||
->set_active(paramBool(initial.params, entry.spec.key, false));
|
||||
break;
|
||||
case ParamSpec::Type::Int:
|
||||
static_cast<Gtk::SpinButton*>(entry.widget)
|
||||
->set_value(static_cast<double>(paramInt(initial.params, entry.spec.key, 0)));
|
||||
break;
|
||||
case ParamSpec::Type::Double:
|
||||
static_cast<Gtk::SpinButton*>(entry.widget)
|
||||
->set_value(paramDouble(initial.params, entry.spec.key, 0.0));
|
||||
break;
|
||||
case ParamSpec::Type::Choice: {
|
||||
const auto value = paramChoice(initial.params, entry.spec.key, entry.spec.defaultChoice);
|
||||
for (std::size_t i = 0; i < entry.spec.choices.size(); ++i) {
|
||||
if (entry.spec.choices[i] == value) {
|
||||
static_cast<Gtk::DropDown*>(entry.widget)->set_selected(i);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
std::vector<LoadFailure> failures;
|
||||
auto images = loadImages(paths, failures);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!sources_->empty()) {
|
||||
requestRender();
|
||||
} else {
|
||||
result_ = Image();
|
||||
saveButton_.set_sensitive(false);
|
||||
previewStack_.set_visible_child("placeholder");
|
||||
status_.set_text("");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rendering ---
|
||||
|
||||
void EffectorWindow::onEffectChanged() {
|
||||
const auto index = effectDropdown_.get_selected();
|
||||
if (index >= effects_.size()) return;
|
||||
effect_ = effects_[index];
|
||||
buildParamWidgets();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::onParamChanged() { 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 (sources_->empty()) {
|
||||
showError("Add at least one source image first.");
|
||||
return;
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::requestRender() {
|
||||
if (suspendRender_ || sources_->empty()) 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 (sources_->empty()) return;
|
||||
if (rendering_) {
|
||||
staleResult_ = true; // a fresh render follows this one
|
||||
return;
|
||||
}
|
||||
rendering_ = true;
|
||||
|
||||
const auto sources = sources_;
|
||||
const auto effect = effect_;
|
||||
const int width = static_cast<int>(widthSpin_.get_value());
|
||||
const int height = static_cast<int>(heightSpin_.get_value());
|
||||
const std::uint64_t seed = seed_;
|
||||
const ParamValues values = paramValues();
|
||||
|
||||
spinner_.start();
|
||||
recreateButton_.set_sensitive(false);
|
||||
|
||||
worker_ = std::thread([this, sources, effect, width, height, seed, values] {
|
||||
Image image;
|
||||
std::string error;
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
try {
|
||||
Rng rng(seed);
|
||||
image = effect->render(*sources, width, height, rng, values);
|
||||
} 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 << effect_->title() << " · " << result_.width() << "×" << result_.height() << " · "
|
||||
<< sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed "
|
||||
<< seed_ << " · " << millis << " ms";
|
||||
status_.set_text(status.str());
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- saving ---
|
||||
|
||||
void EffectorWindow::onSave() {
|
||||
if (result_.empty()) return;
|
||||
|
||||
auto dialog = Gtk::FileDialog::create();
|
||||
dialog->set_title("Save image");
|
||||
dialog->set_initial_name("effector-" + effect_->name() + "-" + 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
|
||||
Reference in New Issue
Block a user