The status line under the preview names every stage of the chain, and a label asks for its whole text as its minimum width. With a long chain that was wider than the pane, so the preview column was laid out wider than it is shown and the picture, centred in it, slid off to the right until a shorter status (such as "Saved ...") let it back. The line is now ellipsized and never asks for more room than it is given; the full text stays readable as its tooltip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1747 lines
66 KiB
C++
1747 lines
66 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);
|
||
// A long chain makes a long status line; left to its natural width it
|
||
// would widen the preview column past the pane and push the picture off
|
||
// centre. Ellipsized, it never asks for more room than it is given.
|
||
status_.set_ellipsize(Pango::EllipsizeMode::END);
|
||
status_.set_margin_start(8);
|
||
status_.set_margin_end(8);
|
||
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("");
|
||
status_.set_tooltip_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());
|
||
status_.set_tooltip_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);
|
||
status_.set_tooltip_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());
|
||
status_.set_tooltip_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
|