Cascade effects through a chain
renderChain runs a list of stages in order: stage 0 works on the loaded sources, every later one on the output of the stage before it, so blur after scanlines softens the scanlines rather than the photographs. The effects themselves are untouched — none of them knows whether its input came from disk. All stages share one Rng and run in sequence, so a seed still reproduces the cascade byte for byte. The sidebar grows an "Effect chain" list: add a stage from a menu built from the registry, reorder it, remove it, and edit the selected stage's params underneath. Each stage keeps its own values. The CLI takes the same chain from repeated -e, with every flag going to the stage whose effect declares it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+214
-59
@@ -1,9 +1,11 @@
|
||||
#include "gui/window.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <sstream>
|
||||
|
||||
#include "core/chain.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
@@ -34,7 +36,7 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
||||
set_default_size(1180, 760);
|
||||
|
||||
effects_ = effects();
|
||||
effect_ = effects_.front();
|
||||
stages_.push_back({effects_.front(), effects_.front()->defaults()});
|
||||
seed_ = Rng::randomSeed();
|
||||
|
||||
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
||||
@@ -69,14 +71,6 @@ void EffectorWindow::buildUi() {
|
||||
header->pack_start(*addButton);
|
||||
addButton_ = addButton;
|
||||
|
||||
std::vector<Glib::ustring> titles;
|
||||
for (const auto& effect : effects_) titles.push_back(effect->title());
|
||||
effectDropdown_.set_model(Gtk::StringList::create(titles));
|
||||
effectDropdown_.set_tooltip_text("Effect");
|
||||
effectDropdown_.property_selected().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onEffectChanged));
|
||||
header->pack_start(effectDropdown_);
|
||||
|
||||
saveButton_.set_sensitive(false);
|
||||
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
|
||||
header->pack_end(saveButton_);
|
||||
@@ -148,10 +142,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
||||
grid->attach(heightSpin_, 1, 1);
|
||||
sidebar->append(*grid);
|
||||
|
||||
sidebar->append(*sectionLabel("Effect settings"));
|
||||
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);
|
||||
sidebar->append(paramGrid_);
|
||||
rebuildChainRows();
|
||||
buildParamWidgets();
|
||||
|
||||
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||||
@@ -202,8 +201,16 @@ void EffectorWindow::buildParamWidgets() {
|
||||
paramWidgets_.clear();
|
||||
ownedWidgets_.clear();
|
||||
|
||||
if (stages_.empty()) {
|
||||
paramHeading_.set_text("No effects");
|
||||
return;
|
||||
}
|
||||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
|
||||
" settings");
|
||||
|
||||
int row = 0;
|
||||
for (const auto& spec : effect_->params()) {
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
auto label = std::make_unique<Gtk::Label>(spec.label);
|
||||
label->set_xalign(0.0F);
|
||||
if (!spec.help.empty()) label->set_tooltip_text(spec.help);
|
||||
@@ -213,7 +220,7 @@ void EffectorWindow::buildParamWidgets() {
|
||||
case ParamSpec::Type::Bool: {
|
||||
auto toggle = std::make_unique<Gtk::Switch>();
|
||||
toggle->set_halign(Gtk::Align::START);
|
||||
toggle->set_active(spec.defaultValue != 0.0);
|
||||
toggle->set_active(paramBool(stage.values, spec.key, spec.defaultValue != 0.0));
|
||||
toggle->property_active().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
widget = std::move(toggle);
|
||||
@@ -225,7 +232,10 @@ void EffectorWindow::buildParamWidgets() {
|
||||
spin->set_range(spec.minimum, spec.maximum);
|
||||
spin->set_increments(spec.step, spec.step * 10);
|
||||
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
|
||||
spin->set_value(spec.defaultValue);
|
||||
spin->set_value(spec.type == ParamSpec::Type::Int
|
||||
? static_cast<double>(paramInt(stage.values, spec.key,
|
||||
static_cast<long>(spec.defaultValue)))
|
||||
: paramDouble(stage.values, spec.key, spec.defaultValue));
|
||||
spin->signal_value_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
widget = std::move(spin);
|
||||
@@ -235,8 +245,9 @@ void EffectorWindow::buildParamWidgets() {
|
||||
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
||||
auto dropdown = std::make_unique<Gtk::DropDown>();
|
||||
dropdown->set_model(Gtk::StringList::create(choices));
|
||||
const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice);
|
||||
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
|
||||
if (spec.choices[i] == spec.defaultChoice) dropdown->set_selected(i);
|
||||
if (spec.choices[i] == current) dropdown->set_selected(i);
|
||||
}
|
||||
dropdown->property_selected().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
@@ -256,7 +267,8 @@ void EffectorWindow::buildParamWidgets() {
|
||||
}
|
||||
|
||||
ParamValues EffectorWindow::paramValues() const {
|
||||
ParamValues values = effect_->defaults();
|
||||
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:
|
||||
@@ -279,43 +291,28 @@ ParamValues EffectorWindow::paramValues() const {
|
||||
return values;
|
||||
}
|
||||
|
||||
// The widgets only ever show one stage, so their values are written back
|
||||
// whenever they change; the other stages keep the settings they were given.
|
||||
void EffectorWindow::storeParams() {
|
||||
if (stages_.empty()) return;
|
||||
stages_[static_cast<std::size_t>(selectedStage_)].values = paramValues();
|
||||
}
|
||||
|
||||
void EffectorWindow::applyInitial(const Options& initial) {
|
||||
for (std::size_t i = 0; i < effects_.size(); ++i) {
|
||||
if (effects_[i]->name() == initial.effect) effectDropdown_.set_selected(i);
|
||||
}
|
||||
// `-e scanlines -e blur` starts the interface with that cascade already set
|
||||
// up, flags included.
|
||||
auto stages = buildChain(initial);
|
||||
if (!stages.empty()) stages_ = std::move(stages);
|
||||
selectedStage_ = 0;
|
||||
rebuildChainRows();
|
||||
buildParamWidgets();
|
||||
|
||||
widthSpin_.set_value(initial.width);
|
||||
heightSpin_.set_value(initial.height);
|
||||
if (initial.seed) {
|
||||
seed_ = *initial.seed;
|
||||
seedEntry_.set_text(std::to_string(seed_));
|
||||
}
|
||||
for (const auto& entry : paramWidgets_) {
|
||||
const auto it = initial.params.find(entry.spec.key);
|
||||
if (it == initial.params.end()) continue;
|
||||
switch (entry.spec.type) {
|
||||
case ParamSpec::Type::Bool:
|
||||
static_cast<Gtk::Switch*>(entry.widget)
|
||||
->set_active(paramBool(initial.params, entry.spec.key, false));
|
||||
break;
|
||||
case ParamSpec::Type::Int:
|
||||
static_cast<Gtk::SpinButton*>(entry.widget)
|
||||
->set_value(static_cast<double>(paramInt(initial.params, entry.spec.key, 0)));
|
||||
break;
|
||||
case ParamSpec::Type::Double:
|
||||
static_cast<Gtk::SpinButton*>(entry.widget)
|
||||
->set_value(paramDouble(initial.params, entry.spec.key, 0.0));
|
||||
break;
|
||||
case ParamSpec::Type::Choice: {
|
||||
const auto value = paramChoice(initial.params, entry.spec.key, entry.spec.defaultChoice);
|
||||
for (std::size_t i = 0; i < entry.spec.choices.size(); ++i) {
|
||||
if (entry.spec.choices[i] == value) {
|
||||
static_cast<Gtk::DropDown*>(entry.widget)->set_selected(i);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!initial.images.empty()) addPaths(initial.images);
|
||||
}
|
||||
|
||||
@@ -589,17 +586,176 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result
|
||||
if (!paths.empty()) addPaths(paths);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rendering ---
|
||||
// ---------------------------------------------------------------- chain ---
|
||||
|
||||
void EffectorWindow::onEffectChanged() {
|
||||
const auto index = effectDropdown_.get_selected();
|
||||
if (index >= effects_.size()) return;
|
||||
effect_ = effects_[index];
|
||||
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(); });
|
||||
box->append(chainList_);
|
||||
|
||||
// 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::onParamChanged() { 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();
|
||||
|
||||
if (stages_.empty()) {
|
||||
result_ = Image();
|
||||
saveButton_.set_sensitive(false);
|
||||
previewStack_.set_visible_child("placeholder");
|
||||
placeholder_.set_text("Add an effect to start.");
|
||||
status_.set_text("");
|
||||
return;
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::moveStage(int index, int delta) {
|
||||
const int target = index + delta;
|
||||
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
|
||||
if (target < 0 || static_cast<std::size_t>(target) >= stages_.size()) return;
|
||||
std::swap(stages_[static_cast<std::size_t>(index)], stages_[static_cast<std::size_t>(target)]);
|
||||
selectedStage_ = target;
|
||||
rebuildChainRows();
|
||||
buildParamWidgets();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::rebuildChainRows() {
|
||||
// Rebuilding drops and re-adds the selection, which would otherwise look
|
||||
// like the user picking a different stage.
|
||||
suspendStageSelect_ = true;
|
||||
// Only the rows: the placeholder is a child of the list box as well, and
|
||||
// removing it would leave the empty list blank.
|
||||
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
|
||||
|
||||
for (std::size_t i = 0; i < stages_.size(); ++i) {
|
||||
const int index = static_cast<int>(i);
|
||||
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
|
||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
|
||||
box->set_margin_top(4);
|
||||
box->set_margin_bottom(4);
|
||||
box->set_margin_start(8);
|
||||
box->set_margin_end(4);
|
||||
|
||||
auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " +
|
||||
stages_[i].effect->title());
|
||||
label->set_xalign(0.0F);
|
||||
label->set_hexpand(true);
|
||||
label->set_ellipsize(Pango::EllipsizeMode::END);
|
||||
label->set_tooltip_text(index == 0 ? "Applied to the source images"
|
||||
: "Applied to the output of the stage above");
|
||||
box->append(*label);
|
||||
|
||||
struct Action {
|
||||
const char* icon;
|
||||
const char* tip;
|
||||
int delta; // 0 means remove
|
||||
};
|
||||
for (const Action& action : {Action{"go-up-symbolic", "Move up", -1},
|
||||
Action{"go-down-symbolic", "Move down", 1},
|
||||
Action{"window-close-symbolic", "Remove", 0}}) {
|
||||
auto* button = Gtk::make_managed<Gtk::Button>();
|
||||
button->set_icon_name(action.icon);
|
||||
button->set_tooltip_text(action.tip);
|
||||
button->add_css_class("flat");
|
||||
const int delta = action.delta;
|
||||
button->signal_clicked().connect([this, index, delta] {
|
||||
if (delta == 0) {
|
||||
removeStage(index);
|
||||
} else {
|
||||
moveStage(index, delta);
|
||||
}
|
||||
});
|
||||
if (delta == -1) button->set_sensitive(index > 0);
|
||||
if (delta == 1) button->set_sensitive(static_cast<std::size_t>(index) + 1 < stages_.size());
|
||||
box->append(*button);
|
||||
}
|
||||
|
||||
row->set_child(*box);
|
||||
chainList_.append(*row);
|
||||
}
|
||||
|
||||
selectStage(selectedStage_);
|
||||
suspendStageSelect_ = false;
|
||||
}
|
||||
|
||||
void EffectorWindow::selectStage(int index) {
|
||||
if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row);
|
||||
}
|
||||
|
||||
void EffectorWindow::onStageSelected() {
|
||||
if (suspendStageSelect_) return;
|
||||
auto* row = chainList_.get_selected_row();
|
||||
if (row == nullptr) return;
|
||||
if (row->get_index() == selectedStage_) return;
|
||||
selectedStage_ = row->get_index();
|
||||
buildParamWidgets();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rendering ---
|
||||
|
||||
void EffectorWindow::onParamChanged() {
|
||||
storeParams();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::onSeedEntered() {
|
||||
try {
|
||||
@@ -622,7 +778,7 @@ void EffectorWindow::onRecreate() {
|
||||
}
|
||||
|
||||
void EffectorWindow::requestRender() {
|
||||
if (suspendRender_ || sources_->empty()) return;
|
||||
if (suspendRender_ || sources_->empty() || stages_.empty()) return;
|
||||
// Spin buttons fire on every step while they are held down; coalesce those
|
||||
// into one render instead of queueing a job per tick.
|
||||
debounce_.disconnect();
|
||||
@@ -635,7 +791,7 @@ void EffectorWindow::requestRender() {
|
||||
}
|
||||
|
||||
void EffectorWindow::startRender() {
|
||||
if (sources_->empty()) return;
|
||||
if (sources_->empty() || stages_.empty()) return;
|
||||
if (rendering_) {
|
||||
staleResult_ = true; // a fresh render follows this one
|
||||
return;
|
||||
@@ -643,22 +799,21 @@ void EffectorWindow::startRender() {
|
||||
rendering_ = true;
|
||||
|
||||
const auto sources = sources_;
|
||||
const auto effect = effect_;
|
||||
const auto stages = stages_; // a snapshot, like the sources
|
||||
const int width = static_cast<int>(widthSpin_.get_value());
|
||||
const int height = static_cast<int>(heightSpin_.get_value());
|
||||
const std::uint64_t seed = seed_;
|
||||
const ParamValues values = paramValues();
|
||||
|
||||
spinner_.start();
|
||||
recreateButton_.set_sensitive(false);
|
||||
|
||||
worker_ = std::thread([this, sources, effect, width, height, seed, values] {
|
||||
worker_ = std::thread([this, sources, stages, width, height, seed] {
|
||||
Image image;
|
||||
std::string error;
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
try {
|
||||
Rng rng(seed);
|
||||
image = effect->render(*sources, width, height, rng, values);
|
||||
image = renderChain(stages, *sources, width, height, rng);
|
||||
} catch (const std::exception& exception) {
|
||||
error = exception.what();
|
||||
}
|
||||
@@ -714,7 +869,7 @@ void EffectorWindow::onRenderFinished() {
|
||||
std::ostringstream status;
|
||||
status.setf(std::ios::fixed);
|
||||
status.precision(1);
|
||||
status << effect_->title() << " · " << result_.width() << "×" << result_.height() << " · "
|
||||
status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · "
|
||||
<< sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed "
|
||||
<< seed_ << " · " << millis << " ms";
|
||||
status_.set_text(status.str());
|
||||
@@ -727,7 +882,7 @@ void EffectorWindow::onSave() {
|
||||
|
||||
auto dialog = Gtk::FileDialog::create();
|
||||
dialog->set_title("Save image");
|
||||
dialog->set_initial_name("effector-" + effect_->name() + "-" + std::to_string(seed_) + ".png");
|
||||
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);
|
||||
});
|
||||
|
||||
+18
-3
@@ -12,6 +12,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/effect.hpp"
|
||||
#include "core/image.hpp"
|
||||
|
||||
@@ -28,6 +29,7 @@ private:
|
||||
Gtk::Widget& buildPreview();
|
||||
void buildParamWidgets();
|
||||
ParamValues paramValues() const;
|
||||
void storeParams(); // widgets -> the selected stage's values
|
||||
void applyInitial(const Options& initial);
|
||||
|
||||
void requestRender(); // coalesced: restarts the short debounce timer
|
||||
@@ -44,7 +46,14 @@ private:
|
||||
void appendSourceRow(const std::string& path);
|
||||
void removeSource(Gtk::ListBoxRow* row);
|
||||
|
||||
void onEffectChanged();
|
||||
Gtk::Widget& buildChainSection();
|
||||
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
||||
void appendStage(const EffectPtr& effect);
|
||||
void removeStage(int index);
|
||||
void moveStage(int index, int delta);
|
||||
void rebuildChainRows();
|
||||
void selectStage(int index);
|
||||
void onStageSelected();
|
||||
void onParamChanged();
|
||||
void onSeedEntered();
|
||||
void onRecreate();
|
||||
@@ -66,7 +75,11 @@ private:
|
||||
// Model
|
||||
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
|
||||
std::vector<EffectPtr> effects_;
|
||||
EffectPtr effect_;
|
||||
// The cascade: stage 0 works on the sources, every later stage on the
|
||||
// output of the one before it.
|
||||
std::vector<ChainStage> stages_;
|
||||
int selectedStage_ = 0;
|
||||
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
|
||||
Image result_;
|
||||
std::uint64_t seed_ = 0;
|
||||
bool suspendRender_ = true;
|
||||
@@ -109,8 +122,10 @@ private:
|
||||
Gtk::SpinButton widthSpin_;
|
||||
Gtk::SpinButton heightSpin_;
|
||||
Gtk::Grid paramGrid_;
|
||||
Gtk::Label paramHeading_;
|
||||
Gtk::Entry seedEntry_;
|
||||
Gtk::DropDown effectDropdown_;
|
||||
Gtk::ListBox chainList_;
|
||||
Gtk::MenuButton addEffectButton_;
|
||||
Gtk::Button recreateButton_{"Recreate"};
|
||||
Gtk::Button saveButton_{"Save As…"};
|
||||
Gtk::Picture picture_;
|
||||
|
||||
Reference in New Issue
Block a user