A picture with a title, a caption and a credit meant typing the same font, size and colour into every text stage, and changing the look meant typing them all again. A project now holds named styles: a stage follows one, and editing it moves every stage that follows it at once. The front-ends learn nothing effect-specific. A ParamSpec can be marked .styled(), saying a style carries it rather than the stage, and Effect::styleKind() names the pool it comes from -- so styleSpecs(kind) is to a style what params() is to a stage, and a second text-ish effect would slot into the styles that already exist. The look is what a style carries; the words and the placement stay on the stage, which is what lets two stages share a typography in two different corners. stageValues() resolves one in a single place, and a stage keeps a full set of values of its own beside the style, so switching it to Custom, dropping the style, or opening the project in a build that does not know it renders exactly the same picture. A style moves stages together; it never holds the only copy of anything. The command line builds no styles and passes none, so it renders what it always did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1737 lines
65 KiB
C++
1737 lines
65 KiB
C++
#include "gui/window.hpp"
|
||
|
||
#include <algorithm>
|
||
#include <chrono>
|
||
#include <cmath>
|
||
#include <cstdint>
|
||
#include <filesystem>
|
||
#include <sstream>
|
||
#include <utility>
|
||
|
||
#include "core/canvas.hpp"
|
||
#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();
|
||
// The chain starts empty: the interface opens on "No effects yet" and the
|
||
// user picks the first stage, rather than being handed one at random.
|
||
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"));
|
||
sidebar->append(buildCanvasSection());
|
||
|
||
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;
|
||
}
|
||
|
||
// Only the first stage is ever handed the sources, so whether the chain wants
|
||
// them at all is `chainNeedsSources`. An effect that starts from nothing (blur,
|
||
// text) makes the whole sources section beside the point, so it is hidden
|
||
// rather than left there to be filled in vain. This is the same declarative
|
||
// answer the render gate uses -- no effect is named here.
|
||
void EffectorWindow::updateSourcesVisible() {
|
||
sourcesExpander_.set_visible(chainNeedsSources(stages_));
|
||
}
|
||
|
||
// 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) + ")");
|
||
}
|
||
|
||
// The canvas is either a named format — a web size, or a sheet of paper at a
|
||
// resolution — or the user's own pixels. A format owns the two spin buttons
|
||
// while it is picked, which is why they are shown greyed out rather than
|
||
// hidden: the size a format works out to is worth seeing.
|
||
Gtk::Widget& EffectorWindow::buildCanvasSection() {
|
||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
||
|
||
// Index 0 is "Custom"; every later index is canvasFormats()[index - 1].
|
||
std::vector<Glib::ustring> labels{"Custom"};
|
||
for (const auto& format : canvasFormats()) {
|
||
labels.push_back(format.label + " (" + canvasFormatDetail(format) + ")");
|
||
}
|
||
formatDrop_.set_model(Gtk::StringList::create(labels));
|
||
formatDrop_.set_selected(0);
|
||
formatDrop_.set_tooltip_text("Standard web and print sizes, or your own pixels");
|
||
formatDrop_.property_selected().signal_changed().connect(
|
||
sigc::mem_fun(*this, &EffectorWindow::syncCanvasWidgets));
|
||
box->append(formatDrop_);
|
||
|
||
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);
|
||
dpiSpin_.set_range(kMinDpi, kMaxDpi);
|
||
dpiSpin_.set_increments(1, 50);
|
||
dpiSpin_.set_value(kDefaultDpi);
|
||
for (auto* spin : {&widthSpin_, &heightSpin_, &dpiSpin_}) {
|
||
spin->set_hexpand(true);
|
||
}
|
||
for (auto* spin : {&widthSpin_, &heightSpin_}) {
|
||
spin->signal_value_changed().connect([this] {
|
||
if (!suspendCanvasSync_) onParamChanged();
|
||
});
|
||
}
|
||
// The dpi decides the pixels of a sheet, so moving it goes through the
|
||
// same path a format change does.
|
||
dpiSpin_.signal_value_changed().connect([this] {
|
||
if (!suspendCanvasSync_) syncCanvasWidgets();
|
||
});
|
||
|
||
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);
|
||
auto* dpiLabel = Gtk::make_managed<Gtk::Label>("DPI");
|
||
dpiLabel->set_xalign(0.0F);
|
||
dpiLabel_ = dpiLabel;
|
||
grid->attach(*widthLabel, 0, 0);
|
||
grid->attach(widthSpin_, 1, 0);
|
||
grid->attach(*heightLabel, 0, 1);
|
||
grid->attach(heightSpin_, 1, 1);
|
||
grid->attach(*dpiLabel, 0, 2);
|
||
grid->attach(dpiSpin_, 1, 2);
|
||
box->append(*grid);
|
||
|
||
// Quietly: the sidebar is still being built, and nothing is to be rendered
|
||
// until the initial options or a project have had their say.
|
||
suspendCanvasSync_ = true;
|
||
syncCanvasWidgets();
|
||
suspendCanvasSync_ = false;
|
||
return *box;
|
||
}
|
||
|
||
std::string EffectorWindow::canvasFormatKey() const {
|
||
const auto selected = formatDrop_.get_selected();
|
||
if (selected == GTK_INVALID_LIST_POSITION || selected == 0) return {};
|
||
const auto index = static_cast<std::size_t>(selected) - 1;
|
||
const auto& formats = canvasFormats();
|
||
return index < formats.size() ? formats[index].key : std::string();
|
||
}
|
||
|
||
// A key the build does not know — an old project, or a hand-edited file — lands
|
||
// on "Custom", which leaves the pixels the project carries alone.
|
||
void EffectorWindow::setCanvasFormat(const std::string& key, int dpi) {
|
||
const bool suspended = suspendCanvasSync_;
|
||
suspendCanvasSync_ = true;
|
||
dpiSpin_.set_value(std::clamp(dpi, kMinDpi, kMaxDpi));
|
||
guint selected = 0;
|
||
const auto& formats = canvasFormats();
|
||
for (std::size_t i = 0; i < formats.size(); ++i) {
|
||
if (formats[i].key == key) selected = static_cast<guint>(i + 1);
|
||
}
|
||
formatDrop_.set_selected(selected);
|
||
syncCanvasWidgets(); // still suspended, so the spins follow without a render
|
||
suspendCanvasSync_ = suspended;
|
||
}
|
||
|
||
void EffectorWindow::syncCanvasWidgets() {
|
||
// Suspended means a caller is putting the canvas together — the initial
|
||
// options, or a project being opened — and will ask for the render itself.
|
||
const bool quiet = suspendCanvasSync_;
|
||
const auto* format = canvasFormatByKey(canvasFormatKey());
|
||
const bool print = format != nullptr && format->print;
|
||
dpiLabel_->set_visible(print);
|
||
dpiSpin_.set_visible(print);
|
||
widthSpin_.set_sensitive(format == nullptr);
|
||
heightSpin_.set_sensitive(format == nullptr);
|
||
|
||
if (format != nullptr) {
|
||
int width = 0;
|
||
int height = 0;
|
||
canvasFormatSize(*format, static_cast<int>(dpiSpin_.get_value()), width, height);
|
||
const bool suspended = suspendCanvasSync_;
|
||
suspendCanvasSync_ = true; // one render for the change, not one per spin
|
||
widthSpin_.set_value(width);
|
||
heightSpin_.set_value(height);
|
||
suspendCanvasSync_ = suspended;
|
||
}
|
||
if (!quiet) onParamChanged();
|
||
}
|
||
|
||
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();
|
||
styleDrop_ = nullptr;
|
||
|
||
if (stages_.empty()) {
|
||
paramHeading_.set_text("No effects");
|
||
return;
|
||
}
|
||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||
// The settings of a stage that is switched off stay editable: it is still
|
||
// being worked on, it just is not in the picture for the moment.
|
||
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
|
||
(stage.enabled ? " settings" : " settings (off)"));
|
||
|
||
int row = 0;
|
||
// The style the stage follows, when its effect takes one at all: the
|
||
// params it carries are then shown and edited on the style rather than on
|
||
// the stage, which is what makes one change move every stage using it.
|
||
const Style* style = selectedStyle();
|
||
if (!stage.effect->styleKind().empty()) {
|
||
auto label = std::make_unique<Gtk::Label>("Style");
|
||
label->set_xalign(0.0F);
|
||
label->set_tooltip_text(
|
||
"A named set of formatting shared by several stages: the font, the colour and the "
|
||
"box, but never the words or the placement.");
|
||
auto widget = buildStyleRow(stage);
|
||
widget->set_hexpand(true);
|
||
paramGrid_.attach(*label, 0, row);
|
||
paramGrid_.attach(*widget, 1, row);
|
||
ownedWidgets_.push_back(std::move(label));
|
||
ownedWidgets_.push_back(std::move(widget));
|
||
++row;
|
||
}
|
||
|
||
for (const auto& spec : stage.effect->params()) {
|
||
// A styled param is read from the style while one is followed, so the
|
||
// widgets show what the stage will actually render with.
|
||
const bool shared = style != nullptr && spec.inStyle;
|
||
const ParamValues& values = shared ? style->values : stage.values;
|
||
|
||
auto label = std::make_unique<Gtk::Label>(spec.label);
|
||
label->set_xalign(0.0F);
|
||
std::string help = spec.help;
|
||
if (shared) {
|
||
label->set_markup(Glib::Markup::escape_text(spec.label) +
|
||
" <span alpha='55%'>(style)</span>");
|
||
if (!help.empty()) help += "\n\n";
|
||
help += "Part of the style \"" + style->name +
|
||
"\": changing it changes every stage that follows it.";
|
||
}
|
||
if (!help.empty()) label->set_tooltip_text(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(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(values, spec.key,
|
||
static_cast<long>(spec.defaultValue)))
|
||
: paramDouble(values, spec.key, spec.defaultValue));
|
||
spin->signal_value_changed().connect(
|
||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||
widget = std::move(spin);
|
||
break;
|
||
}
|
||
case ParamSpec::Type::Path: {
|
||
// An entry holding the path, with a chooser beside it; the
|
||
// entry is what paramValues() reads back.
|
||
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||
auto* entry = Gtk::make_managed<Gtk::Entry>();
|
||
entry->set_hexpand(true);
|
||
entry->set_text(paramText(values, spec.key, ""));
|
||
entry->set_placeholder_text("No image chosen");
|
||
entry->signal_activate().connect(
|
||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||
auto* choose = Gtk::make_managed<Gtk::Button>("Choose…");
|
||
const int index = selectedStage_;
|
||
const std::string key = spec.key;
|
||
const std::string title = spec.label;
|
||
choose->signal_clicked().connect(
|
||
[this, index, key, title] { onChoosePath(index, key, title); });
|
||
box->append(*entry);
|
||
box->append(*choose);
|
||
paramWidgets_.push_back({spec, entry});
|
||
widget = std::move(box);
|
||
break;
|
||
}
|
||
case ParamSpec::Type::Text: {
|
||
auto entry = std::make_unique<Gtk::Entry>();
|
||
entry->set_text(paramText(values, spec.key, spec.defaultChoice));
|
||
// Re-rendering on every keystroke would start a render per
|
||
// letter typed, so the text is taken when the entry is left or
|
||
// Enter is pressed.
|
||
entry->signal_activate().connect(
|
||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||
auto focus = Gtk::EventControllerFocus::create();
|
||
focus->signal_leave().connect(
|
||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||
entry->add_controller(focus);
|
||
widget = std::move(entry);
|
||
break;
|
||
}
|
||
case ParamSpec::Type::Color: {
|
||
auto button = std::make_unique<Gtk::ColorDialogButton>(Gtk::ColorDialog::create());
|
||
button->set_halign(Gtk::Align::START);
|
||
std::uint8_t rgb[3];
|
||
paramColor(values, spec.key, spec.defaultChoice, rgb);
|
||
Gdk::RGBA colour;
|
||
colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0);
|
||
button->set_rgba(colour);
|
||
button->property_rgba().signal_changed().connect(
|
||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||
widget = std::move(button);
|
||
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(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);
|
||
// A path attaches a box but reads back from the entry inside it, which
|
||
// it has registered itself.
|
||
if (spec.type != ParamSpec::Type::Path) 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::Path:
|
||
case ParamSpec::Type::Text:
|
||
values[entry.spec.key] =
|
||
std::string(static_cast<Gtk::Entry*>(entry.widget)->get_text());
|
||
break;
|
||
case ParamSpec::Type::Color: {
|
||
const auto colour = static_cast<Gtk::ColorDialogButton*>(entry.widget)->get_rgba();
|
||
const std::uint8_t rgb[3] = {
|
||
static_cast<std::uint8_t>(std::lround(colour.get_red() * 255.0)),
|
||
static_cast<std::uint8_t>(std::lround(colour.get_green() * 255.0)),
|
||
static_cast<std::uint8_t>(std::lround(colour.get_blue() * 255.0))};
|
||
values[entry.spec.key] = formatColor(rgb);
|
||
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;
|
||
auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||
const ParamValues values = paramValues();
|
||
stage.values = values;
|
||
|
||
// A param the style carries is written to the style too -- that is how the
|
||
// look of every stage following it is changed from here. The stage keeps
|
||
// its own copy of the same value, so leaving the style, or losing it, does
|
||
// not move the picture.
|
||
if (Style* style = selectedStyle()) {
|
||
for (const auto& spec : stage.effect->params()) {
|
||
if (!spec.inStyle) continue;
|
||
const auto found = values.find(spec.key);
|
||
if (found != values.end()) style->values[spec.key] = found->second;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------- text styles ---
|
||
|
||
std::vector<std::string> EffectorWindow::styleNames(const std::string& kind) const {
|
||
std::vector<std::string> names;
|
||
for (const auto& style : styles_) {
|
||
if (style.kind == kind) names.push_back(style.name);
|
||
}
|
||
return names;
|
||
}
|
||
|
||
Style* EffectorWindow::selectedStyle() {
|
||
return const_cast<Style*>(std::as_const(*this).selectedStyle());
|
||
}
|
||
|
||
const Style* EffectorWindow::selectedStyle() const {
|
||
if (stages_.empty()) return nullptr;
|
||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||
return findStyle(styles_, stage.effect->styleKind(), stage.style);
|
||
}
|
||
|
||
bool EffectorWindow::styleNameTaken(const std::string& kind, const std::string& name) const {
|
||
return findStyle(styles_, kind, name) != nullptr;
|
||
}
|
||
|
||
std::unique_ptr<Gtk::Widget> EffectorWindow::buildStyleRow(const ChainStage& stage) {
|
||
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||
|
||
const auto names = styleNames(stage.effect->styleKind());
|
||
// "Custom" first so that the stage standing on its own is the plain case,
|
||
// and "New style…" last so that the names in between keep their indices.
|
||
std::vector<Glib::ustring> entries{"Custom"};
|
||
for (const auto& name : names) entries.emplace_back(name);
|
||
entries.emplace_back("New style…");
|
||
|
||
auto* drop = Gtk::make_managed<Gtk::DropDown>();
|
||
drop->set_model(Gtk::StringList::create(entries));
|
||
drop->set_hexpand(true);
|
||
suspendStyleSelect_ = true;
|
||
drop->set_selected(0);
|
||
for (std::size_t i = 0; i < names.size(); ++i) {
|
||
if (names[i] == stage.style) drop->set_selected(i + 1);
|
||
}
|
||
suspendStyleSelect_ = false;
|
||
drop->property_selected().signal_changed().connect(
|
||
sigc::mem_fun(*this, &EffectorWindow::onStyleSelected));
|
||
box->append(*drop);
|
||
styleDrop_ = drop;
|
||
|
||
// Renaming and removing only make sense for a style that is being followed.
|
||
auto* rename = Gtk::make_managed<Gtk::Button>("Rename…");
|
||
rename->set_sensitive(!stage.style.empty());
|
||
rename->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRenameStyle));
|
||
box->append(*rename);
|
||
|
||
auto* remove = Gtk::make_managed<Gtk::Button>("Remove");
|
||
remove->set_sensitive(!stage.style.empty());
|
||
remove->set_tooltip_text(
|
||
"Drops the style. Every stage following it keeps the look it has and goes back to its own "
|
||
"settings.");
|
||
remove->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRemoveStyle));
|
||
box->append(*remove);
|
||
|
||
return box;
|
||
}
|
||
|
||
void EffectorWindow::onStyleSelected() {
|
||
if (suspendStyleSelect_ || styleDrop_ == nullptr || stages_.empty()) return;
|
||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||
const auto names = styleNames(stage.effect->styleKind());
|
||
const auto index = styleDrop_->get_selected();
|
||
|
||
if (index == 0) {
|
||
adoptStyle("");
|
||
} else if (index <= names.size()) {
|
||
adoptStyle(names[index - 1]);
|
||
} else {
|
||
onNewStyle(); // the last entry
|
||
}
|
||
}
|
||
|
||
// Pointing the stage at a style, or at nothing. The style's values are copied
|
||
// onto the stage as well, so the project file always says what the stage would
|
||
// render if the style went away -- which is the same picture.
|
||
void EffectorWindow::adoptStyle(const std::string& name) {
|
||
if (stages_.empty()) return;
|
||
auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||
stage.style = name;
|
||
if (const Style* style = selectedStyle()) {
|
||
for (const auto& spec : stage.effect->params()) {
|
||
if (!spec.inStyle) continue;
|
||
const auto found = style->values.find(spec.key);
|
||
if (found != style->values.end()) stage.values[spec.key] = found->second;
|
||
}
|
||
}
|
||
buildParamWidgets();
|
||
requestRender();
|
||
}
|
||
|
||
void EffectorWindow::onNewStyle() {
|
||
if (stages_.empty()) return;
|
||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||
const std::string kind = stage.effect->styleKind();
|
||
if (kind.empty()) return;
|
||
|
||
askStyleName("New style", "Style " + std::to_string(styleNames(kind).size() + 1),
|
||
[this, kind](const std::string& name) {
|
||
// Seeded from the stage it was made on, so the style
|
||
// starts as the look that is already on screen.
|
||
Style style;
|
||
style.kind = kind;
|
||
style.name = name;
|
||
style.values = styleDefaults(kind);
|
||
const auto& current = stages_[static_cast<std::size_t>(selectedStage_)];
|
||
for (const auto& spec : current.effect->params()) {
|
||
if (!spec.inStyle) continue;
|
||
const auto found = current.values.find(spec.key);
|
||
if (found != current.values.end()) style.values[spec.key] = found->second;
|
||
}
|
||
styles_.push_back(std::move(style));
|
||
adoptStyle(name);
|
||
});
|
||
}
|
||
|
||
void EffectorWindow::onRenameStyle() {
|
||
const Style* style = selectedStyle();
|
||
if (style == nullptr) return;
|
||
const std::string kind = style->kind;
|
||
const std::string was = style->name;
|
||
|
||
askStyleName("Rename style", was, [this, kind, was](const std::string& name) {
|
||
if (name == was) return;
|
||
for (auto& candidate : styles_) {
|
||
if (candidate.kind == kind && candidate.name == was) candidate.name = name;
|
||
}
|
||
// Every stage that followed the old name follows the new one: a rename
|
||
// must not quietly cut a stage loose from its look.
|
||
for (auto& stage : stages_) {
|
||
if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name;
|
||
}
|
||
buildParamWidgets();
|
||
});
|
||
}
|
||
|
||
void EffectorWindow::onRemoveStyle() {
|
||
const Style* style = selectedStyle();
|
||
if (style == nullptr) return;
|
||
const std::string kind = style->kind;
|
||
const std::string name = style->name;
|
||
|
||
// The stages keep the values they were rendering with, so dropping a style
|
||
// changes nothing in the picture -- it only stops the stages moving
|
||
// together.
|
||
for (auto& stage : stages_) {
|
||
if (stage.effect->styleKind() == kind && stage.style == name) stage.style.clear();
|
||
}
|
||
styles_.erase(std::remove_if(styles_.begin(), styles_.end(),
|
||
[&](const Style& candidate) {
|
||
return candidate.kind == kind && candidate.name == name;
|
||
}),
|
||
styles_.end());
|
||
buildParamWidgets();
|
||
requestRender();
|
||
}
|
||
|
||
// Built from scratch each time it is needed, like the progress window, so the
|
||
// entry it holds is a raw pointer that closeStyleWindow() nulls.
|
||
void EffectorWindow::askStyleName(const std::string& title, const std::string& initial,
|
||
const std::function<void(const std::string&)>& accept) {
|
||
closeStyleWindow();
|
||
if (stages_.empty()) return;
|
||
const std::string kind = stages_[static_cast<std::size_t>(selectedStage_)].effect->styleKind();
|
||
|
||
styleWindow_ = std::make_shared<Gtk::Window>();
|
||
styleWindow_->set_title(title);
|
||
styleWindow_->set_transient_for(*this);
|
||
styleWindow_->set_modal(true);
|
||
styleWindow_->set_resizable(false);
|
||
styleWindow_->set_default_size(360, -1);
|
||
|
||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||
box->set_margin(18);
|
||
|
||
auto* entry = Gtk::make_managed<Gtk::Entry>();
|
||
entry->set_text(initial);
|
||
entry->set_activates_default(false);
|
||
box->append(*entry);
|
||
styleNameEntry_ = entry;
|
||
|
||
auto* error = Gtk::make_managed<Gtk::Label>("");
|
||
error->set_xalign(0.0F);
|
||
error->add_css_class("dim-label");
|
||
error->set_visible(false);
|
||
box->append(*error);
|
||
styleNameError_ = error;
|
||
|
||
auto* buttons = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||
buttons->set_halign(Gtk::Align::END);
|
||
|
||
auto* cancel = Gtk::make_managed<Gtk::Button>("Cancel");
|
||
cancel->signal_clicked().connect([this] {
|
||
closeStyleWindow();
|
||
// The dropdown may be sitting on "New style…"; put it back on what the
|
||
// stage actually follows.
|
||
buildParamWidgets();
|
||
});
|
||
buttons->append(*cancel);
|
||
|
||
auto* confirm = Gtk::make_managed<Gtk::Button>("OK");
|
||
confirm->add_css_class("suggested-action");
|
||
// A name that is empty or already in the pool is refused in place rather
|
||
// than silently changed, so two styles can never share a name.
|
||
const auto submit = [this, kind, accept] {
|
||
if (styleNameEntry_ == nullptr) return;
|
||
const std::string name = styleNameEntry_->get_text();
|
||
const char* complaint = nullptr;
|
||
if (name.empty()) {
|
||
complaint = "A style needs a name.";
|
||
} else if (styleNameTaken(kind, name)) {
|
||
complaint = "There is already a style with that name.";
|
||
}
|
||
if (complaint != nullptr) {
|
||
if (styleNameError_ != nullptr) {
|
||
styleNameError_->set_text(complaint);
|
||
styleNameError_->set_visible(true);
|
||
}
|
||
return;
|
||
}
|
||
closeStyleWindow();
|
||
accept(name);
|
||
};
|
||
confirm->signal_clicked().connect(submit);
|
||
entry->signal_activate().connect(submit);
|
||
buttons->append(*confirm);
|
||
box->append(*buttons);
|
||
|
||
styleWindow_->signal_close_request().connect(
|
||
[this] {
|
||
closeStyleWindow();
|
||
buildParamWidgets();
|
||
return true;
|
||
},
|
||
false);
|
||
|
||
styleWindow_->set_child(*box);
|
||
styleWindow_->present();
|
||
entry->grab_focus();
|
||
}
|
||
|
||
void EffectorWindow::closeStyleWindow() {
|
||
styleNameEntry_ = nullptr;
|
||
styleNameError_ = nullptr;
|
||
if (!styleWindow_) return;
|
||
styleWindow_->set_hide_on_close(false);
|
||
styleWindow_->hide();
|
||
// Closing is asked for by the window's own buttons and by its close
|
||
// request, so it is torn down once that signal has finished running rather
|
||
// than underneath it.
|
||
Glib::signal_idle().connect_once([window = styleWindow_] {});
|
||
styleWindow_.reset();
|
||
}
|
||
|
||
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);
|
||
setCanvasFormat(initial.format, initial.dpi); // a format fills the spins itself
|
||
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);
|
||
|
||
// The first stage that actually runs is the one handed the sources, which
|
||
// is not stage 1 once stage 1 is switched off.
|
||
const auto running = enabledStages(stages_);
|
||
const int firstRunning =
|
||
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
|
||
|
||
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);
|
||
|
||
// The check is what switches a stage off: it keeps its settings and
|
||
// its place in the chain, and the render simply skips it.
|
||
auto* check = Gtk::make_managed<Gtk::CheckButton>();
|
||
check->set_active(stages_[i].enabled);
|
||
check->set_tooltip_text("Apply this effect");
|
||
check->set_valign(Gtk::Align::CENTER);
|
||
// Connected after set_active, so filling in the rebuilt rows does not
|
||
// look like the user reaching for the check.
|
||
check->signal_toggled().connect([this, index, check] {
|
||
setStageEnabled(index, check->get_active());
|
||
});
|
||
box->append(*check);
|
||
|
||
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);
|
||
if (!stages_[i].enabled) label->add_css_class("dim-label");
|
||
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
|
||
: index == firstRunning ? "Applied to the source images"
|
||
: "Applied to the output so far");
|
||
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;
|
||
updateSourcesVisible();
|
||
}
|
||
|
||
// Switching a stage off leaves everything about it alone -- its settings, its
|
||
// place in the chain, the stage the params below are showing -- so the only
|
||
// visible change is the dimmed row and the picture the render comes back with.
|
||
void EffectorWindow::setStageEnabled(int index, bool enabled) {
|
||
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
|
||
auto& stage = stages_[static_cast<std::size_t>(index)];
|
||
if (stage.enabled == enabled) return;
|
||
stage.enabled = enabled;
|
||
rebuildChainRows(); // the row dims, and another stage may now lead the chain
|
||
if (index == selectedStage_) buildParamWidgets(); // the heading says "off"
|
||
requestRender();
|
||
}
|
||
|
||
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::onChoosePath(int stage, const std::string& key, const std::string& label) {
|
||
auto dialog = Gtk::FileDialog::create();
|
||
dialog->set_title("Select the " + label);
|
||
|
||
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(*this, [this, dialog, stage, key](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||
std::string path;
|
||
try {
|
||
const auto file = dialog->open_finish(result);
|
||
if (file) path = file->get_path();
|
||
} catch (const Glib::Error&) {
|
||
return; // cancelled
|
||
}
|
||
if (path.empty() || stage < 0 || static_cast<std::size_t>(stage) >= stages_.size()) return;
|
||
// The widgets may be showing another stage by now: store what they hold
|
||
// first, then write the path into the stage that asked for it.
|
||
storeParams();
|
||
stages_[static_cast<std::size_t>(stage)].values[key] = path;
|
||
if (stage == selectedStage_) buildParamWidgets();
|
||
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 (enabledStages(stages_).empty()) return false;
|
||
return !sources_->empty() || !chainNeedsSources(stages_);
|
||
}
|
||
|
||
std::string EffectorWindow::missingText() const {
|
||
if (stages_.empty()) return "Add an effect to start.";
|
||
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
|
||
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 auto styles = styles_; // the same, so the worker reads nothing live
|
||
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, styles, 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, styles);
|
||
} 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.styles = styles_;
|
||
project.width = static_cast<int>(widthSpin_.get_value());
|
||
project.height = static_cast<int>(heightSpin_.get_value());
|
||
project.format = canvasFormatKey();
|
||
project.dpi = static_cast<int>(dpiSpin_.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;
|
||
closeStyleWindow(); // it names a style of the project being replaced
|
||
clearSources();
|
||
|
||
stages_ = project.stages;
|
||
// Before the rows and the params: both ask which style a stage follows.
|
||
styles_ = project.styles;
|
||
selectedStage_ = 0;
|
||
rebuildChainRows();
|
||
buildParamWidgets();
|
||
|
||
widthSpin_.set_value(project.width);
|
||
heightSpin_.set_value(project.height);
|
||
setCanvasFormat(project.format, project.dpi);
|
||
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() {
|
||
applyProject(Project(), ""); // same empty chain the window opens with
|
||
}
|
||
|
||
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
|