// SPDX-License-Identifier: GPL-2.0-or-later /** * Pattern editor widget for "Fill and Stroke" dialog * * Copyright (C) 2022 Michael Kowalski * * Released under GNU GPL v2+, read the file 'COPYING' for more information. */ #include "pattern-editor.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "object/sp-defs.h" #include "object/sp-root.h" #include "style.h" #include "ui/builder-utils.h" #include "ui/svg-renderer.h" #include "io/resource.h" #include "manipulation/copy-resource.h" #include "pattern-manager.h" #include "pattern-manipulation.h" #include "preferences.h" #include "util/units.h" #include "widgets/spw-utilities.h" namespace Inkscape { namespace UI { namespace Widget { using namespace Inkscape::IO; // default size of pattern image in a list static const int ITEM_WIDTH = 45; // get slider position 'index' (linear) and transform that into gap percentage (non-linear) static double slider_to_gap(double index, double upper) { auto v = std::tan(index / (upper + 1) * M_PI / 2.0) * 500; return std::round(v / 20) * 20; } // transform gap percentage value into slider position static double gap_to_slider(double gap, double upper) { return std::atan(gap / 500) * (upper + 1) / M_PI * 2; } // tile size slider functions static int slider_to_tile(double index) { return 30 + static_cast(index) * 5; } static double tile_to_slider(int tile) { return (tile - 30) / 5.0; } Glib::ustring get_attrib(SPPattern* pattern, const char* attrib) { auto value = pattern->getAttribute(attrib); return value ? value : ""; } double get_attrib_num(SPPattern* pattern, const char* attrib) { auto val = get_attrib(pattern, attrib); return strtod(val.c_str(), nullptr); } const double ANGLE_STEP = 15.0; PatternEditor::PatternEditor(const char* prefs, Inkscape::PatternManager& manager) : _manager(manager), _builder(create_builder("pattern-edit.glade")), _offset_x(get_widget(_builder, "offset-x")), _offset_y(get_widget(_builder, "offset-y")), _scale_x(get_widget(_builder, "scale-x")), _scale_y(get_widget(_builder, "scale-y")), _angle_btn(get_widget(_builder, "angle")), _orient_slider(get_widget(_builder, "orient")), _gap_x_slider(get_widget(_builder, "gap-x")), _gap_y_slider(get_widget(_builder, "gap-y")), _edit_btn(get_widget(_builder, "edit-pattern")), _preview_img(get_widget(_builder, "preview")), _preview(get_widget(_builder, "preview-box")), _color_btn(get_widget(_builder, "color-btn")), _color_label(get_widget(_builder, "color-label")), _paned(get_widget(_builder, "paned")), _main_grid(get_widget(_builder, "main-box")), _input_grid(get_widget(_builder, "input-grid")), _stock_gallery(get_widget(_builder, "flowbox")), _doc_gallery(get_widget(_builder, "doc-flowbox")), _link_scale(get_widget(_builder, "link-scale")), _name_box(get_widget(_builder, "pattern-name")), _combo_set(get_widget(_builder, "pattern-combo")), _search_box(get_widget(_builder, "search")), _tile_slider(get_widget(_builder, "tile-slider")), _show_names(get_widget(_builder, "show-names")), _prefs(prefs) { _color_picker = std::make_unique( _("Pattern color"), "", 0x7f7f7f00, true, &get_widget(_builder, "color-btn")); _color_picker->use_transparency(false); _color_picker->connectChanged([=](guint color){ if (_update.pending()) return; _signal_color_changed.emit(color); }); _tile_size = Inkscape::Preferences::get()->getIntLimited(_prefs + "/tileSize", ITEM_WIDTH, 30, 1000); _tile_slider.set_value(tile_to_slider(_tile_size)); _tile_slider.signal_change_value().connect([=](Gtk::ScrollType st, double value){ if (_update.pending()) return true; auto scoped(_update.block()); auto size = slider_to_tile(value); if (size != _tile_size) { _tile_slider.set_value(tile_to_slider(size)); // change pattern tile size _tile_size = size; update_pattern_tiles(); Inkscape::Preferences::get()->setInt(_prefs + "/tileSize", size); } return true; }); auto show_labels = Inkscape::Preferences::get()->getBool(_prefs + "/showLabels", false); _show_names.set_active(show_labels); _show_names.signal_toggled().connect([=](){ // toggle pattern labels _stock_pattern_store.store.refresh(); _doc_pattern_store.store.refresh(); Inkscape::Preferences::get()->setBool(_prefs + "/showLabels", _show_names.get_active()); }); const auto max = 180.0 / ANGLE_STEP; _orient_slider.set_range(-max, max); _orient_slider.set_increments(1, 1); _orient_slider.set_digits(0); _orient_slider.set_value(0); _orient_slider.signal_change_value().connect([=](Gtk::ScrollType st, double value){ if (_update.pending()) return false; auto scoped(_update.block()); // slider works with 15deg discrete steps _angle_btn.set_value(round(CLAMP(value, -max, max)) * ANGLE_STEP); _signal_changed.emit(); return true; }); for (auto slider : {&_gap_x_slider, &_gap_y_slider}) { slider->set_increments(1, 1); slider->set_digits(0); slider->set_value(0); slider->signal_format_value().connect([=](double val){ auto upper = slider->get_adjustment()->get_upper(); return Glib::ustring::format(std::fixed, std::setprecision(0), slider_to_gap(val, upper)) + "%"; }); slider->signal_change_value().connect([=](Gtk::ScrollType st, double value){ if (_update.pending()) return false; _signal_changed.emit(); return true; }); } _angle_btn.signal_value_changed().connect([=]() { if (_update.pending() || !_angle_btn.is_sensitive()) return; auto scoped(_update.block()); auto angle = _angle_btn.get_value(); _orient_slider.set_value(round(angle / ANGLE_STEP)); _signal_changed.emit(); }); _link_scale.signal_clicked().connect([=](){ if (_update.pending()) return; auto scoped(_update.block()); _scale_linked = !_scale_linked; if (_scale_linked) { // this is simplistic _scale_x.set_value(_scale_y.get_value()); } update_scale_link(); _signal_changed.emit(); }); for (auto el : {&_scale_x, &_scale_y, &_offset_x, &_offset_y}) { el->signal_value_changed().connect([=]() { if (_update.pending()) return; if (_scale_linked && (el == &_scale_x || el == &_scale_y)) { auto scoped(_update.block()); // enforce uniform scaling (el == &_scale_x) ? _scale_y.set_value(el->get_value()) : _scale_x.set_value(el->get_value()); } _signal_changed.emit(); }); } _name_box.signal_changed().connect([=](){ if (_update.pending()) return; _signal_changed.emit(); }); _search_box.signal_search_changed().connect([=](){ if (_update.pending()) return; // filter patterns _filter_text = _search_box.get_text(); apply_filter(false); apply_filter(true); }); // populate combo box with all patern categories auto pattern_categories = _manager.get_categories()->children(); int cat_count = pattern_categories.size(); for (auto row : pattern_categories) { auto name = row.get_value(_manager.columns.name); _combo_set.append(name); } get_widget(_builder, "previous").signal_clicked().connect([=](){ int previous = _combo_set.get_active_row_number() - 1; if (previous >= 0) _combo_set.set_active(previous); }); get_widget(_builder, "next").signal_clicked().connect([=](){ auto next = _combo_set.get_active_row_number() + 1; if (next < cat_count) _combo_set.set_active(next); }); _combo_set.signal_changed().connect([=](){ // select pattern category to show auto index = _combo_set.get_active_row_number(); select_pattern_set(index); Inkscape::Preferences::get()->setInt(_prefs + "/currentSet", index); }); bind_store(_doc_gallery, _doc_pattern_store); bind_store(_stock_gallery, _stock_pattern_store); _stock_gallery.signal_child_activated().connect([=](Gtk::FlowBoxChild* box){ if (_update.pending()) return; auto scoped(_update.block()); auto pat = _stock_pattern_store.widgets_to_pattern[box]; update_ui(pat); _doc_gallery.unselect_all(); _signal_changed.emit(); }); _doc_gallery.signal_child_activated().connect([=](Gtk::FlowBoxChild* box){ if (_update.pending()) return; auto scoped(_update.block()); auto pat = _doc_pattern_store.widgets_to_pattern[box]; update_ui(pat); _stock_gallery.unselect_all(); _signal_changed.emit(); }); _edit_btn.signal_clicked().connect([=](){ _signal_edit.emit(); }); _paned.set_position(Inkscape::Preferences::get()->getIntLimited(_prefs + "/handlePos", 50, 10, 9999)); _paned.property_position().signal_changed().connect([=](){ Inkscape::Preferences::get()->setInt(_prefs + "/handlePos", _paned.get_position()); }); // current pattern category _combo_set.set_active(Inkscape::Preferences::get()->getIntLimited(_prefs + "/currentSet", 0, 0, std::max(cat_count - 1, 0))); update_scale_link(); pack_start(_main_grid); } PatternEditor::~PatternEditor() noexcept {} void PatternEditor::bind_store(Gtk::FlowBox& list, PatternStore& pat) { pat.store.set_filter([=](const Glib::RefPtr& p){ if (!p) return false; if (_filter_text.empty()) return true; auto name = Glib::ustring(p->label).lowercase(); auto expr = _filter_text.lowercase(); auto pos = name.find(expr); return pos != Glib::ustring::npos; }); list.bind_list_store(pat.store.get_store(), [=, &pat](const Glib::RefPtr& item){ auto box = Gtk::make_managed(Gtk::ORIENTATION_VERTICAL); auto image = Gtk::make_managed(item->pix); box->pack_start(*image); auto name = Glib::ustring(item->label.c_str()); if (_show_names.get_active()) { auto label = Gtk::make_managed(name); label->get_style_context()->add_class("small-font"); // limit label size to tile size label->set_ellipsize(Pango::EllipsizeMode::ELLIPSIZE_END); label->set_max_width_chars(0); label->set_size_request(_tile_size); box->pack_end(*label); } image->set_tooltip_text(name); box->show_all(); auto cbox = Gtk::make_managed(); cbox->add(*box); cbox->get_style_context()->add_class("pattern-item-box"); pat.widgets_to_pattern[cbox] = item; cbox->set_size_request(_tile_size, _tile_size); return cbox; }); } void PatternEditor::select_pattern_set(int index) { auto sets = _manager.get_categories()->children(); if (index >= 0 && index < sets.size()) { auto row = sets[index]; if (auto category = row.get_value(_manager.columns.category)) { set_stock_patterns(category->patterns); } } } void PatternEditor::update_scale_link() { _link_scale.remove(); _link_scale.add(get_widget(_builder, _scale_linked ? "image-linked" : "image-unlinked")); } void PatternEditor::update_widgets_from_pattern(Glib::RefPtr& pattern) { _input_grid.set_sensitive(!!pattern); PatternItem empty; const auto& item = pattern ? *pattern.get() : empty; _name_box.set_text(item.label.c_str()); _scale_x.set_value(item.transform.xAxis().length()); _scale_y.set_value(item.transform.yAxis().length()); // TODO if needed // auto units = get_attrib(pattern, "patternUnits"); _scale_linked = item.uniform_scale; update_scale_link(); _offset_x.set_value(item.offset.x()); _offset_y.set_value(item.offset.y()); auto degrees = 180.0 / M_PI * Geom::atan2(item.transform.xAxis()); _orient_slider.set_value(round(degrees / ANGLE_STEP)); _angle_btn.set_value(degrees); double x_index = gap_to_slider(item.gap[Geom::X], _gap_x_slider.get_adjustment()->get_upper()); _gap_x_slider.set_value(x_index); double y_index = gap_to_slider(item.gap[Geom::Y], _gap_y_slider.get_adjustment()->get_upper()); _gap_y_slider.set_value(y_index); if (item.color.has_value()) { _color_picker->setRgba32(item.color->toRGBA32(1.0)); _color_btn.set_sensitive(); _color_label.set_opacity(1.0); // hack: sensitivity doesn't change appearance, so using opacity directly } else { _color_picker->setRgba32(0); _color_btn.set_sensitive(false); _color_label.set_opacity(0.6); _color_picker->closeWindow(); } } void PatternEditor::update_ui(Glib::RefPtr pattern) { update_widgets_from_pattern(pattern); } // sort patterns in-place by name/id void sort_patterns(std::vector>& list) { std::sort(list.begin(), list.end(), [](Glib::RefPtr& a, Glib::RefPtr& b) { if (!a || !b) return false; if (a->label == b->label) { return a->id < b->id; } return a->label < b->label; }); } // given a pattern, create a PatternItem instance that describes it; // input pattern can be a link or a root pattern Glib::RefPtr create_pattern_item(PatternManager& manager, SPPattern* pattern, int tile_size, double scale) { auto item = manager.get_item(pattern); if (item && scale > 0) { item->pix = manager.get_image(pattern, tile_size, tile_size, scale); } return item; } // update editor UI void PatternEditor::set_selected(SPPattern* pattern) { auto scoped(_update.block()); _stock_gallery.unselect_all(); // current pattern (should be a link) auto link_pattern = pattern; if (pattern) pattern = pattern->rootPattern(); if (pattern && pattern != link_pattern) { _current_pattern.id = pattern->getId(); _current_pattern.link_id = link_pattern->getId(); } else { _current_pattern.id.clear(); _current_pattern.link_id.clear(); } auto item = create_pattern_item(_manager, link_pattern, 0, 0); update_widgets_from_pattern(item); auto list = update_doc_pattern_list(pattern ? pattern->document : nullptr); if (pattern) { // patch up tile image on a list of document root patterns, it might have changed; // color attribute for instance is being set directly on the root pattern; // other attributes are per-object, so should not be taken into account when rendering tile for (auto& pattern_item : list) { if (pattern_item->id == item->id && pattern_item->collection == nullptr) { // update preview const double device_scale = get_scale_factor(); pattern_item->pix = _manager.get_image(pattern, _tile_size, _tile_size, device_scale); item->pix = pattern_item->pix; break; } } } set_active(_doc_gallery, _doc_pattern_store, item); // generate large preview of selected pattern Cairo::RefPtr surface; if (link_pattern) { const double device_scale = get_scale_factor(); auto size = _preview.get_allocation(); const int m = 1; if (size.get_width() <= m || size.get_height() <= m) { // widgets not resized yet, choose arbitrary size, so preview is not missing when widget is shown size.set_width(200); size.set_height(200); } // use white for checkerboard since most stock patterns are black unsigned int background = 0xffffffff; surface = _manager.get_preview(link_pattern, size.get_width(), size.get_height(), background, device_scale); } _preview_img.set(surface); } // generate preview images for patterns std::vector> create_pattern_items(PatternManager& manager, const std::vector& list, int tile_size, double device_scale) { std::vector> output; output.reserve(list.size()); for (auto pat : list) { if (auto item = create_pattern_item(manager, pat, tile_size, device_scale)) { output.push_back(item); } } return output; } // populate store with document patterns if list has changed, minimize amount of work by using cached previews std::vector> PatternEditor::update_doc_pattern_list(SPDocument* document) { auto list = sp_get_pattern_list(document); std::shared_ptr nil; const double device_scale = get_scale_factor(); // create pattern items (cheap), but skip preview generation (expansive) auto patterns = create_pattern_items(_manager, list, 0, 0); bool modified = false; for (auto&& item : patterns) { auto it = _cached_items.find(item->id); if (it != end(_cached_items)) { // reuse cached preview image if (!item->pix) item->pix = it->second->pix; } else { if (!item->pix) { // generate preview for newly added pattern item->pix = _manager.get_image(cast(document->getObjectById(item->id)), _tile_size, _tile_size, device_scale); } modified = true; _cached_items[item->id] = item; } } update_store(patterns, _doc_gallery, _doc_pattern_store); return patterns; } void PatternEditor::set_document(SPDocument* document) { _current_document = document; _cached_items.clear(); update_doc_pattern_list(document); } // populate store with stock patterns void PatternEditor::set_stock_patterns(const std::vector& list) { const double device_scale = get_scale_factor(); auto patterns = create_pattern_items(_manager, list, _tile_size, device_scale); sort_patterns(patterns); update_store(patterns, _stock_gallery, _stock_pattern_store); } void PatternEditor::apply_filter(bool stock) { auto scoped(_update.block()); if (!stock) { _doc_pattern_store.store.apply_filter(); } else { _stock_pattern_store.store.apply_filter(); } } void PatternEditor::update_store(const std::vector>& list, Gtk::FlowBox& gallery, PatternStore& pat) { auto selected = get_active(gallery, pat); if (pat.store.assign(list)) { // reselect current set_active(gallery, pat, selected); } } Glib::RefPtr PatternEditor::get_active(Gtk::FlowBox& gallery, PatternStore& pat) { auto empty = Glib::RefPtr(); auto sel = gallery.get_selected_children(); if (sel.size() == 1) { return pat.widgets_to_pattern[sel.front()]; } else { return empty; } } std::pair, SPDocument*> PatternEditor::get_active() { SPDocument* stock = nullptr; auto sel = get_active(_doc_gallery, _doc_pattern_store); if (!sel) { sel = get_active(_stock_gallery, _stock_pattern_store); stock = sel ? sel->collection : nullptr; } return std::make_pair(sel, stock); } void PatternEditor::set_active(Gtk::FlowBox& gallery, PatternStore& pat, Glib::RefPtr item) { bool selected = false; if (item) { gallery.foreach([=,&selected,&pat,&gallery](Gtk::Widget& widget){ if (auto box = dynamic_cast(&widget)) { if (auto pattern = pat.widgets_to_pattern[box]) { if (pattern->id == item->id && pattern->collection == item->collection) { gallery.select_child(*box); if (item->pix) { // update preview, it might be stale sp_traverse_widget_tree(box->get_child(), [&](Gtk::Widget* widget){ if (auto image = dynamic_cast(widget)) { image->set(item->pix); return true; // stop } return false; // continue }); } selected = true; } } } }); } if (!selected) { gallery.unselect_all(); } } std::pair PatternEditor::get_selected() { // document patterns first auto active = get_active(); auto sel = active.first; auto stock_doc = active.second; std::string id; if (sel) { if (stock_doc) { // for stock pattern, report its root pattern ID return std::make_pair(sel->id, stock_doc); } else { // for current document, if selection hasn't changed return linked pattern ID // so that we can modify its properties (transform, offset, gap) if (sel->id == _current_pattern.id) { return std::make_pair(_current_pattern.link_id, nullptr); } // different pattern from current document selected; use its root pattern // as a starting point; link pattern will be injected by adjust_pattern() return std::make_pair(sel->id, nullptr); } } else { // if nothing is selected, pick first stock pattern, so we have something to assign // to selected object(s); without it, pattern editing will not be activated if (auto first = _stock_pattern_store.store.get_store()->get_item(0)) { return std::make_pair(first->id, first->collection); } // no stock patterns available return std::make_pair("", nullptr); } } std::optional PatternEditor::get_selected_color() { auto pat = get_active(); if (pat.first && pat.first->color.has_value()) { return _color_picker->get_current_color(); } return std::optional(); // color not supported } Geom::Point PatternEditor::get_selected_offset() { return Geom::Point(_offset_x.get_value(), _offset_y.get_value()); } Geom::Affine PatternEditor::get_selected_transform() { Geom::Affine matrix; matrix *= Geom::Scale(_scale_x.get_value(), _scale_y.get_value()); matrix *= Geom::Rotate(_angle_btn.get_value() / 180.0 * M_PI); auto pat = get_active(); if (pat.first) { //TODO: this is imperfect; calculate better offset, if possible // this translation is kept so there's no sudden jump when editing pattern attributes matrix.setTranslation(pat.first->transform.translation()); } return matrix; } bool PatternEditor::is_selected_scale_uniform() { return _scale_linked; } Geom::Scale PatternEditor::get_selected_gap() { auto vx = _gap_x_slider.get_value(); auto gap_x = slider_to_gap(vx, _gap_x_slider.get_adjustment()->get_upper()); auto vy = _gap_y_slider.get_value(); auto gap_y = slider_to_gap(vy, _gap_y_slider.get_adjustment()->get_upper()); return Geom::Scale(gap_x, gap_y); } Glib::ustring PatternEditor::get_label() { return _name_box.get_text(); } SPPattern* get_pattern(const PatternItem& item, SPDocument* document) { auto doc = item.collection ? item.collection : document; if (!doc) return nullptr; return cast(doc->getObjectById(item.id)); } void regenerate_tile_images(PatternManager& manager, PatternStore& pat_store, int tile_size, double device_scale, SPDocument* current) { auto& patterns = pat_store.store.get_items(); for (auto& item : patterns) { if (auto pattern = get_pattern(*item.get(), current)) { item->pix = manager.get_image(pattern, tile_size, tile_size, device_scale); } } pat_store.store.refresh(); } void PatternEditor::update_pattern_tiles() { const double device_scale = get_scale_factor(); regenerate_tile_images(_manager, _doc_pattern_store, _tile_size, device_scale, _current_document); regenerate_tile_images(_manager, _stock_pattern_store, _tile_size, device_scale, nullptr); } } // namespace Widget } // namespace UI } // namespace Inkscape