diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6fe2019 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* otto-link diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 2503cb2..d8db2d3 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -4,6 +4,11 @@ on: push: branches: [main, dev] +permissions: + contents: write + pages: write + id-token: write + jobs: doxygen: runs-on: ubuntu-latest diff --git a/Meta/include/meta/core/abstract_attribute.hpp b/Meta/include/meta/core/abstract_attribute.hpp index aa8a3ab..861d771 100644 --- a/Meta/include/meta/core/abstract_attribute.hpp +++ b/Meta/include/meta/core/abstract_attribute.hpp @@ -14,6 +14,7 @@ #include +#include "meta/core/event.hpp" #include "meta/core/meta_object.hpp" #include "meta/serialization/serialization_mode.hpp" @@ -36,6 +37,9 @@ class AbstractAttribute : public MetaObject public: virtual ~AbstractAttribute() = default; + /// Untyped event fired whenever the attribute value changes. + Event value_changed_event; + /// Returns the attribute name. virtual const std::string &name() const = 0; diff --git a/Meta/include/meta/core/attribute.hpp b/Meta/include/meta/core/attribute.hpp index 4b40fd9..0f2259b 100644 --- a/Meta/include/meta/core/attribute.hpp +++ b/Meta/include/meta/core/attribute.hpp @@ -66,6 +66,22 @@ template class Attribute : public AbstractAttribute Attribute(std::string name, T value) : name_(std::move(name)), value_(std::move(value)) { + value_changed_conn_ = value_changed.subscribe( + [this](const T &) { value_changed_event.notify(*this); }); + } + + /// Sets value and notifies subscribers. + void set_value(const T &new_value) + { + value_ = new_value; + value_changed.notify(value_); + } + + /// Sets value (by move) and notifies subscribers. + void set_value(T &&new_value) + { + value_ = std::move(new_value); + value_changed.notify(value_); } /// Get attribute name. @@ -179,8 +195,9 @@ template class Attribute : public AbstractAttribute } private: - std::string name_; - T value_; + std::string name_; + T value_; + EventConnection value_changed_conn_; }; } // namespace meta \ No newline at end of file diff --git a/Meta/include/meta/core/attribute_container.hpp b/Meta/include/meta/core/attribute_container.hpp index fc5baaa..d650ebb 100644 --- a/Meta/include/meta/core/attribute_container.hpp +++ b/Meta/include/meta/core/attribute_container.hpp @@ -59,6 +59,9 @@ concept StringLike = std::is_same_v, std::string> || class AttributeContainer : public MetaObject { public: + Event attribute_added; + Event attribute_removed; + // ------------------------------------------------------------------------- // Capacity // ------------------------------------------------------------------------- @@ -140,6 +143,8 @@ class AttributeContainer : public MetaObject // keep track of insertion order insertion_order_.push_back(name); + attribute_added.notify(*ptr); + return ptr; } @@ -174,6 +179,8 @@ class AttributeContainer : public MetaObject // keep track of insertion order insertion_order_.push_back(name); + attribute_added.notify(*ptr); + return ptr; } @@ -197,6 +204,8 @@ class AttributeContainer : public MetaObject // keep track of insertion order insertion_order_.push_back(name); + attribute_added.notify(*ptr); + return ptr; } @@ -226,6 +235,8 @@ class AttributeContainer : public MetaObject // keep track of insertion order insertion_order_.push_back(name); + attribute_added.notify(*ptr); + return ptr; } diff --git a/Meta/include/meta/core/container_group.hpp b/Meta/include/meta/core/container_group.hpp index 54983f2..8d04b38 100644 --- a/Meta/include/meta/core/container_group.hpp +++ b/Meta/include/meta/core/container_group.hpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include "meta/core/attribute_container.hpp" #include "meta/core/meta_object.hpp" @@ -81,7 +83,7 @@ class ContainerGroup : public MetaObject */ const AttributeContainer *find(const std::string &key) const; - /// Returns the attribute names in insertion order. + /// Returns the container names in insertion order. const std::vector &insertion_order() const; /** @@ -90,9 +92,77 @@ class ContainerGroup : public MetaObject */ void set_current(const std::string &key); + /** + * @brief Set the active container to the first container in the list. + * @throws std::runtime_error if there are no containers in the group. + */ + void set_current_to_first(); + /// Clear all containers. void clear(); + // ------------------------------------------------------------------------- + // Attribute Synchronization + // ------------------------------------------------------------------------- + + /** + * @brief Detects attribute keys shared across at least two containers with + * matching types. + * @return Vector of shared attribute keys in insertion order. + */ + std::vector shared_attributes() const; + + /** + * @brief Checks whether an attribute key is shared across at least two + * containers with matching types. + * @param key Attribute identifier. + * @return true if shared, false otherwise. + */ + bool is_shared(const std::string &key) const; + + /** + * @brief Enable or disable synchronization for an attribute key across + * containers in this group. + * + * When enabled, the attribute's current value (from the active container, or + * the first container containing it) is propagated to all matching + * containers, and subsequent updates in any container are kept in sync. + * + * @param key Attribute identifier. + * @param synchronize Whether to synchronize. + */ + void set_synchronized(const std::string &key, bool synchronize = true); + + /** + * @brief Check whether an attribute is marked for synchronization. + * @param key Attribute identifier. + * @return true if synchronized. + */ + bool is_synchronized(const std::string &key) const; + + /** + * @brief Get the set of currently synchronized attribute keys. + * @return Constant reference to the set of synchronized attribute names. + */ + const std::unordered_set &synchronized_attributes() const; + + /** + * @brief Synchronizes all detected shared attributes across containers. + * @param synchronize Whether to enable or disable synchronization for all. + */ + void synchronize_all(bool synchronize = true); + + /// Disables synchronization for all attributes. + void clear_synchronizations(); + + /** + * @brief Manually propagate the value of an attribute from the active + * container (or first containing container) to all other matching + * containers in the group. + * @param key Attribute identifier. + */ + void sync_attribute(const std::string &key); + // ------------------------------------------------------------------------- // Serialization // ------------------------------------------------------------------------- @@ -135,6 +205,19 @@ class ContainerGroup : public MetaObject std::vector insertion_order_; AttributeContainer *current_ = nullptr; + std::unordered_set synchronized_attributes_; + bool is_synchronizing_ = false; + + // Track event connections per container + std::unordered_map> + container_connections_; + + void bind_container(const std::string &key, AttributeContainer &container); + void bind_attribute(const std::string &container_key, + AbstractAttribute &attr); + void sync_attribute_across_containers(const std::string &key, + AbstractAttribute &source); + /** * @brief Removes stale entries from insertion_order_ that no longer exist * in attributes_ (e.g. after external erasure or clear). diff --git a/Meta/src/attribute_container.cpp b/Meta/src/attribute_container.cpp index 5b48497..20c1bb3 100644 --- a/Meta/src/attribute_container.cpp +++ b/Meta/src/attribute_container.cpp @@ -38,6 +38,10 @@ ConstAttrIterator AttributeContainer::cend() const void AttributeContainer::clear() { Logger::log()->trace("AttributeContainer::clear"); + + for (const auto &name : insertion_order_) + attribute_removed.notify(name); + attributes_.clear(); compact_insertion_order(); } @@ -179,6 +183,7 @@ void AttributeContainer::json_from(const nlohmann::json &j, std::move(new_attr)); it = insert_it; + attribute_added.notify(*it->second); } else { diff --git a/Meta/src/container_group.cpp b/Meta/src/container_group.cpp index 5cf7791..aee8137 100644 --- a/Meta/src/container_group.cpp +++ b/Meta/src/container_group.cpp @@ -29,9 +29,101 @@ AttributeContainer &ContainerGroup::add(const std::string &key) insertion_order_.push_back(key); + bind_container(key, *it->second); + return *it->second; } +void ContainerGroup::bind_container(const std::string &key, + AttributeContainer &container) +{ + Logger::log()->trace("ContainerGroup::bind_container: key = {}", key); + + auto &conns = container_connections_[key]; + + // Subscribe to attribute_added + conns.push_back(container.attribute_added.subscribe( + [this, key](AbstractAttribute &attr) + { + bind_attribute(key, attr); + if (is_synchronized(attr.name())) + { + if (!is_synchronizing_) + { + // Find a source container that has this attribute + AbstractAttribute *source = nullptr; + if (current_ && current_ != find(key)) + { + source = current_->find(attr.name()); + } + if (!source) + { + for (const auto &cname : insertion_order_) + { + if (cname == key) continue; + auto *c = find(cname); + if (c && (source = c->find(attr.name()))) break; + } + } + if (source && source->type() == attr.type()) + { + is_synchronizing_ = true; + attr.set_from_any(source->to_any()); + is_synchronizing_ = false; + } + } + } + })); + + // Bind any attributes already in the container + for (auto &attr : container) + { + bind_attribute(key, *attr.second); + } +} + +void ContainerGroup::bind_attribute(const std::string &container_key, + AbstractAttribute &attr) +{ + auto &conns = container_connections_[container_key]; + conns.push_back(attr.value_changed_event.subscribe( + [this, attr_name = attr.name()](AbstractAttribute &source) + { + if (is_synchronizing_) return; + if (!is_synchronized(attr_name)) return; + + is_synchronizing_ = true; + sync_attribute_across_containers(attr_name, source); + is_synchronizing_ = false; + })); +} + +void ContainerGroup::sync_attribute_across_containers(const std::string &key, + AbstractAttribute &source) +{ + std::any val = source.to_any(); + std::type_index src_type = source.type(); + + for (auto &[cname, container] : containers_) + { + if (!container) continue; + auto *target_attr = container->find(key); + if (!target_attr || target_attr == &source) continue; + + if (target_attr->type() == src_type) + { + target_attr->set_from_any(val); + } + else + { + Logger::log()->warn("ContainerGroup::sync_attribute: type mismatch for " + "attribute '{}' in container '{}'", + key, + cname); + } + } +} + void ContainerGroup::compact_insertion_order() { Logger::log()->trace("ContainerGroup::compact_insertion_order"); @@ -101,6 +193,7 @@ bool ContainerGroup::erase(const std::string &key) const bool was_current = (current_ == it->second.get()); + container_connections_.erase(key); containers_.erase(it); if (was_current) @@ -153,14 +246,170 @@ void ContainerGroup::set_current(const std::string &key) Logger::log()->trace("ContainerGroup::set_current: success = {}", key); } +void ContainerGroup::set_current_to_first() +{ + Logger::log()->trace("ContainerGroup::set_current_to_first"); + + if (insertion_order_.empty()) + { + Logger::log()->trace("ContainerGroup::set_current_to_first: no containers"); + throw std::runtime_error("No containers in group"); + } + + set_current(insertion_order_.front()); +} + void ContainerGroup::clear() { Logger::log()->trace("ContainerGroup::clear"); + container_connections_.clear(); containers_.clear(); insertion_order_.clear(); + synchronized_attributes_.clear(); current_ = nullptr; } +std::vector ContainerGroup::shared_attributes() const +{ + std::unordered_map> + counts; + std::vector order; + + for (const auto &cname : insertion_order_) + { + auto it = containers_.find(cname); + if (it == containers_.end() || !it->second) continue; + + for (const auto &attr_name : it->second->insertion_order()) + { + const auto *attr = it->second->find(attr_name); + if (!attr) continue; + + if (!counts.contains(attr_name)) + { + order.push_back(attr_name); + } + counts[attr_name][attr->type()]++; + } + } + + std::vector result; + for (const auto &attr_name : order) + { + for (const auto &[type, count] : counts[attr_name]) + { + if (count >= 2) + { + result.push_back(attr_name); + break; + } + } + } + return result; +} + +bool ContainerGroup::is_shared(const std::string &key) const +{ + std::unordered_map type_counts; + for (const auto &[_, container] : containers_) + { + if (!container) continue; + if (const auto *attr = container->find(key)) + { + type_counts[attr->type()]++; + if (type_counts[attr->type()] >= 2) + { + return true; + } + } + } + return false; +} + +void ContainerGroup::set_synchronized(const std::string &key, bool synchronize) +{ + Logger::log()->trace("ContainerGroup::set_synchronized: key='{}', sync={}", + key, + synchronize); + + if (synchronize) + { + synchronized_attributes_.insert(key); + sync_attribute(key); + } + else + { + synchronized_attributes_.erase(key); + } +} + +bool ContainerGroup::is_synchronized(const std::string &key) const +{ + return synchronized_attributes_.contains(key); +} + +const std::unordered_set &ContainerGroup::synchronized_attributes() + const +{ + return synchronized_attributes_; +} + +void ContainerGroup::synchronize_all(bool synchronize) +{ + Logger::log()->trace("ContainerGroup::synchronize_all: sync={}", synchronize); + + if (synchronize) + { + for (const auto &attr_name : shared_attributes()) + { + set_synchronized(attr_name, true); + } + } + else + { + clear_synchronizations(); + } +} + +void ContainerGroup::clear_synchronizations() +{ + Logger::log()->trace("ContainerGroup::clear_synchronizations"); + synchronized_attributes_.clear(); +} + +void ContainerGroup::sync_attribute(const std::string &key) +{ + Logger::log()->trace("ContainerGroup::sync_attribute: key='{}'", key); + + AbstractAttribute *source = nullptr; + if (current_) + { + source = current_->find(key); + } + + if (!source) + { + for (const auto &cname : insertion_order_) + { + auto it = containers_.find(cname); + if (it != containers_.end() && it->second) + { + if ((source = it->second->find(key))) + { + break; + } + } + } + } + + if (source) + { + is_synchronizing_ = true; + sync_attribute_across_containers(key, *source); + is_synchronizing_ = false; + } +} + nlohmann::json ContainerGroup::json_to(SerializationMode mode) const { Logger::log()->trace("ContainerGroup::json_to"); @@ -172,6 +421,11 @@ nlohmann::json ContainerGroup::json_to(SerializationMode mode) const j["current"] = *current_name; } + if (!synchronized_attributes_.empty()) + { + j["synchronized_attributes"] = synchronized_attributes_; + } + nlohmann::json containers_json = nlohmann::json::object(); for (const auto &name : insertion_order_) { @@ -220,7 +474,7 @@ void ContainerGroup::json_from(const nlohmann::json &j, for (const auto &[name, container_val] : containers_json->items()) { - if (name == "current") + if (name == "current" || name == "synchronized_attributes") { continue; } @@ -272,6 +526,18 @@ void ContainerGroup::json_from(const nlohmann::json &j, current_name); } } + + if (j.contains("synchronized_attributes") && + j["synchronized_attributes"].is_array()) + { + for (const auto &attr_val : j["synchronized_attributes"]) + { + if (attr_val.is_string()) + { + set_synchronized(attr_val.get(), true); + } + } + } } size_t ContainerGroup::size() const { return containers_.size(); } diff --git a/MetaUI/qt/include/meta_qt/container_widget.hpp b/MetaUI/qt/include/meta_qt/container_widget.hpp index 06e971a..803a59a 100644 --- a/MetaUI/qt/include/meta_qt/container_widget.hpp +++ b/MetaUI/qt/include/meta_qt/container_widget.hpp @@ -11,6 +11,8 @@ #include "meta_common.hpp" #include "meta_qt/meta_widget.hpp" +#include "meta_qt/ui/control.hpp" +#include "meta_qt/widgets/collapsible_section.hpp" namespace meta::qt { @@ -31,16 +33,35 @@ enum GroupSwitchMode GSM_COMBO_BOX ///< Use a combo box for switching groups }; +/** @brief Builds the widget for a single attribute. + * + * Optional override for per-attribute row rendering. If empty, rows are built + * using the DesignRegistry for options.design. + */ +using AttributeRowRenderer = std::function; + +/** @brief Builds the collapsible section used for a category. + * + * Optional override for category section chrome. If empty, the SectionFactory + * associated with options.design in DesignRegistry is used. + */ +using SectionFactory = + std::function; + /// Options controlling how attribute containers are rendered. struct ContainerRenderOptions { // clang-format off + std::string design = "stock"; ///< Visual design name from DesignRegistry + RowContext row_context = {}; ///< Context passed to row controls CategoryPolicy category_policy = CategoryPolicy::CP_SMART; ///< Category organization strategy GroupSwitchMode group_switch_mode = GroupSwitchMode::GSM_TABS; ///< Container group switching style std::string root_category_name = META_ROOT_CATEGORY; ///< Optional root category label std::vector insertion_order = {}; ///< Explicit ordering of categories std::optional collapse_regex = std::nullopt; ///< Regex used to collapse categories bool snapshot_manager = false; ///< Add snapshot manager widget + AttributeRowRenderer row_renderer = {}; ///< Optional custom row builder override + SectionFactory section_factory = {}; ///< Optional custom section builder override // clang-format on }; @@ -64,9 +85,10 @@ void insert_attribute(CategoryNode &root, std::string compute_flattened_path(CategoryNode *node); /// Renders a flat list of attributes into a Qt layout. -void render_flat(CategoryNode &node, - QVBoxLayout *layout, - std::vector &collected_widgets); +void render_flat(CategoryNode &node, + QVBoxLayout *layout, + std::vector &collected_widgets, + const AttributeRowRenderer &row_renderer = {}); /// Renders a category tree using hierarchical grouping. void render_category(meta::AttributeContainer &container, diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/check_row.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/check_row.hpp new file mode 100644 index 0000000..76221e5 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/check_row.hpp @@ -0,0 +1,59 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include + +#include "meta_common.hpp" + +#include "meta_qt/ui/control.hpp" +#include "meta_qt/ui/glide.hpp" + +namespace meta::qt::industrial +{ + +/** @brief Label plus sliding switch for a bool attribute. + * + * The industrial design's Toggle, second only to the float slider by volume. + * + * Deliberately built on the same Control/bind machinery as ParamSlider despite + * having no drag and no range -- if the binder needed special-casing for a + * control this simple, the abstraction would not be carrying its weight. + */ +class CheckRow : public Control +{ + Q_OBJECT + +public: + CheckRow(Attribute &attr, + const RowContext &ctx, + QWidget *parent = nullptr); + + /// A bool always has a renderable state; nothing to decline. + static bool can_render(const Attribute &) { return true; } + + bool get() const override { return value_; } + void set(const bool &value) override; + + QSize sizeHint() const override; + +protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + +private: + QRect switch_rect() const; + void toggle(); + + bool value_ = false; + std::string label_; + std::string key_; + + Glide *glide_ = nullptr; ///< knob travel, 0 = off, 1 = on + qreal knob_ = 0.0; + bool pressed_ = false; +}; + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp new file mode 100644 index 0000000..b1291fa --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp @@ -0,0 +1,162 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include +#include + +#include +#include +#include + +#include "meta_common.hpp" + +#include "meta_qt/ui/control.hpp" + +class QVariantAnimation; + +namespace meta::qt::industrial +{ + +/** @brief Option list shown when a combo is open. + * + * A Qt::Popup of our own rather than QComboBox's view, because the stock popup + * composites a frame and an item view that are styled separately: mid-open you + * see one surface and once settled another, and the two rarely agree on colour. + * Owning the whole surface is the only way to make it one thing. + * + * Four separate bugs live in Qt popups, all handled here: + * + * - Opening on press means the matching release, which lands outside, dismisses + * it again. Callers must open on *release*. + * - Overriding mousePressEvent suppresses Qt's built-in "press outside + * dismisses", so an outside press has to be handled explicitly. + * - destroyed() is too late to guard against reopening; hideEvent is not. + * - Clicking the field while open goes to the popup, which closes, and the same + * click then reaches the field and reopens it, so it never appears to close. + * should_swallow_reopen() exists for exactly that. + */ +class ComboPopup : public QWidget +{ + Q_OBJECT + +public: + ComboPopup(const Theme &theme, + const QStringList &items, + int current, + QWidget *parent); + + /// Show below `field_global`, flipping above when there is no room below. + void popup_for(const QRect &field_global); + + /// True while a click should be ignored because it is the reopen half of a + /// close. + static bool should_swallow_reopen(); + +signals: + void selected(int index); + +protected: + void paintEvent(QPaintEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void hideEvent(QHideEvent *event) override; + +private: + int index_at(const QPoint &pos) const; + int row_height() const; + + /// Portion of the fixed-size window currently revealed by the open animation. + QRect card_rect() const; + + const Theme *theme_ = nullptr; + QStringList items_; + int current_ = -1; + int hovered_ = -1; + QVariantAnimation *open_animation_ = nullptr; + int full_height_ = 0; + int revealed_ = 0; + bool flipped_ = false; +}; + +/// Shared closed-state painting for both combo flavours. +void paint_combo_field(QWidget &widget, + const Theme &theme, + const QString &label, + const QString &value, + bool open, + bool modified, + bool locked); + +/** @brief Dropdown for an int attribute carrying enum_items. + * + * EnumComboBox, roughly 5% of the rows in a Hesiod node panel. + */ +class EnumCombo : public Control +{ + Q_OBJECT + +public: + EnumCombo(Attribute &attr, + const RowContext &ctx, + QWidget *parent = nullptr); + + /// Needs enum_items to have anything to show. + static bool can_render(const Attribute &attr); + + int get() const override { return value_; } + void set(const int &value) override; + + QSize sizeHint() const override; + +protected: + void paintEvent(QPaintEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void on_state_changed() override { update(); } + +private: + void open_popup(); + + int value_ = 0; + std::vector> items_; + std::string label_; + bool open_ = false; +}; + +/** @brief Dropdown for a string attribute carrying allowed_values. + * + * ComboBox, plus the ButtonGrid preset, which resolves to the same control. + */ +class StringCombo : public Control +{ + Q_OBJECT + +public: + StringCombo(Attribute &attr, + const RowContext &ctx, + QWidget *parent = nullptr); + + static bool can_render(const Attribute &attr); + + std::string get() const override { return value_; } + void set(const std::string &value) override; + + QSize sizeHint() const override; + +protected: + void paintEvent(QPaintEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void on_state_changed() override { update(); } + +private: + void open_popup(); + + std::string value_; + std::vector items_; + std::string label_; + bool open_ = false; +}; + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/industrial.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/industrial.hpp new file mode 100644 index 0000000..ac7f93f --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/industrial.hpp @@ -0,0 +1,28 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include + +namespace meta::qt::industrial +{ + +/// Design name to pass to render_row() / DesignRegistry. +inline constexpr char kDesignName[] = "industrial"; + +/** @brief Register every industrial control with the DesignRegistry. + * + * Idempotent, so a host may call it unconditionally at startup. + * + * Only the widget types this design actually implements are registered. It also + * registers the stock design and declares stock as its fallback, so anything + * not yet ported still renders -- a partial design stays a usable panel rather + * than a handful of rows with gaps between them. + * + * "stock" is a peer flavour, not a privileged fallback: selecting it directly + * gives the unmodified Qt look, which makes A/B comparison a settings change + * rather than a rebuild. + */ +void register_design(); + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp new file mode 100644 index 0000000..57621d4 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp @@ -0,0 +1,79 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include + +#include "meta_common.hpp" + +#include "meta_qt/designs/industrial/slider_chrome.hpp" +#include "meta_qt/ui/control.hpp" +#include "meta_qt/ui/glide.hpp" + +class QLineEdit; + +namespace meta::qt::industrial +{ + +/** @brief Label / rail / value-field row for an int attribute. + * + * SliderInt, roughly 13% of the rows in a Hesiod node panel once Seed is + * counted. + * + * Shares its chrome with ParamSlider through slider_chrome, so the two rows + * cannot drift apart visually. What differs is the value semantics: the rail + * quantises to whole numbers, and the glide runs over the integer range rather + * than a normalised float, so the readout never shows a value the model does + * not hold. + */ +class IntSlider : public Control +{ + Q_OBJECT + +public: + IntSlider(Attribute &attr, + const RowContext &ctx, + QWidget *parent = nullptr); + + /// A rail needs max > min to span; without it the row falls back to stock. + static bool can_render(const Attribute &attr); + + int get() const override { return value_; } + void set(const int &value) override; + + QSize sizeHint() const override; + +protected: + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + void handle_wheel(QWheelEvent *event) override; + void on_state_changed() override; + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + qreal to_norm(int value) const; + int from_norm(qreal t) const; + + void set_from_position(int x); + void apply_value(int value, bool glide); + void refresh_field(); + void restyle_field(bool editing = false); + + int min_ = 0; + int max_ = 1; + int value_ = 0; + std::string label_; + std::string category_; + std::string key_; + + Glide *glide_ = nullptr; ///< animates the painted position only + qreal norm_ = 0.0; + QLineEdit *field_ = nullptr; + bool dragging_ = false; +}; + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/panel_chrome.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/panel_chrome.hpp new file mode 100644 index 0000000..1c80bf5 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/panel_chrome.hpp @@ -0,0 +1,30 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include + +#include "meta_qt/ui/theme.hpp" + +namespace meta::qt::industrial +{ + +/** @brief Scrollbar styling for the panel's scroll area. + * + * Thin, no arrows, handle only. The scrollbar should be reserved permanently by + * the caller (ScrollBarAlwaysOn): an as-needed bar appearing on expand narrows + * the viewport and reflows every row. + */ +QString scrollbar_stylesheet(const Theme &theme); + +/** @brief Tooltip styling. + * + * Applied application-wide by whoever owns the app, because QToolTip is styled + * globally rather than per-widget. + * + * Note this cannot animate. QToolTip is a static utility with no widget to + * attach an animation to; fading requires replacing it with a custom popup. + */ +QString tooltip_stylesheet(const Theme &theme); + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp new file mode 100644 index 0000000..c1f221f --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp @@ -0,0 +1,89 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include + +#include "meta_common.hpp" + +#include "meta_qt/designs/industrial/slider_chrome.hpp" +#include "meta_qt/ui/control.hpp" +#include "meta_qt/ui/glide.hpp" + +class QLineEdit; + +namespace meta::qt::industrial +{ + +/** @brief Label / rail / value-field row for a float attribute. + * + * The industrial design's SliderFloat, covering roughly 58% of the rows in a + * Hesiod node panel. + * + * Painting follows the state matrix strictly: the rail fill is *always* the + * group accent and never encodes state; only the label and value text change + * colour between default, modified and locked. + */ +class ParamSlider : public Control +{ + Q_OBJECT + +public: + ParamSlider(Attribute &attr, + const RowContext &ctx, + QWidget *parent = nullptr); + + /** @brief Decline attributes with no usable range. + * + * A rail needs `max > min` to span. Without both keys meta::common::min/max + * return the numeric limits, which produces a rail no drag can meaningfully + * address -- so the row falls back to the stock spin box instead. + */ + static bool can_render(const Attribute &attr); + + float get() const override { return value_; } + void set(const float &value) override; + + QSize sizeHint() const override; + +protected: + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseDoubleClickEvent(QMouseEvent *event) override; + void handle_wheel(QWheelEvent *event) override; + void on_state_changed() override; + + /// Watches the value field's focus so its chrome can follow the edit state. + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + // --- value <-> normalised position + qreal to_norm(float value) const; + float from_norm(qreal t) const; + + void set_from_position(int x); + void apply_norm(qreal t); + QString format_value(float value) const; + void refresh_field(); + void restyle_field(bool editing = false); + + float min_ = 0.f; + float max_ = 1.f; + float value_ = 0.f; + bool log_scale_ = false; + int decimals_ = 2; + std::string label_; + std::string category_; + std::string key_; ///< attribute name, for the defaults lookup on reset + + Glide *glide_ = nullptr; ///< animates the displayed position, 0..1 + qreal norm_ = 0.0; ///< what is painted; may lag value_ mid-glide + QLineEdit *field_ = nullptr; + bool dragging_ = false; + bool hovered_rail_ = false; +}; + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp new file mode 100644 index 0000000..0590e12 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp @@ -0,0 +1,108 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include "meta_qt/container_widget.hpp" +#include "meta_qt/ui/theme.hpp" +#include "meta_qt/widgets/collapsible_section.hpp" + +class QVariantAnimation; + +namespace meta::qt::industrial +{ + +/** @brief Fixed-height window onto a widget kept at its natural size. + * + * The body is given its full height regardless of how much of it is shown, and + * this widget simply crops it, because a child is clipped to its parent. + * + * That is the whole point. Animating a collapse by constraining the body makes + * the layout negotiate: a live layout treats the shortfall as a squeeze and + * compresses the rows, a Fixed policy clamps a maximumHeight back up to the + * size hint, and a fixed height has to land exactly on the final hint or it + * snaps when released. None of that arithmetic happens here -- the reveal is + * just a number, and the body never changes size at all. + */ +class ClipBox : public QWidget +{ +public: + explicit ClipBox(QWidget *parent = nullptr); + + /// Takes ownership of `body` as its only child. + void set_body(QWidget *body); + + /// Show `px` of the body, measured from its top. + void set_reveal(int px); + + /// Track the body's natural height instead of a fixed reveal. + void follow_body(); + + /// The body's natural height at the current width. + int body_height() const; + + QSize sizeHint() const override; + +protected: + void resizeEvent(QResizeEvent *event) override; + + /// Watches the body for size-hint changes so the crop follows it. + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + QWidget *body_ = nullptr; + int reveal_ = 0; + bool follow_ = true; +}; + +/** @brief Collapsible section that glides open and shut. + * + * The header is restyled through a stylesheet on the inherited QToolButton, so + * this class only has to own the animation. + * + * The collapse is animated by moving a ClipBox's reveal, never by constraining + * the body. Every earlier attempt here tried to squeeze the body itself and + * each failed differently: a fixed height snapped when released because it did + * not land on the final size hint; a maximumHeight was clamped straight back up + * by the body's own Fixed policy; and leaving the body Preferred let the parent + * layout redistribute space across *every* section, so one animating section + * visibly nudged all the others, worse the further down the column they sat. + * + * Cropping sidesteps all of it. The body is never resized, so there is no + * negotiation to get wrong. + */ +class Section : public CollapsibleSection +{ + Q_OBJECT + +public: + Section(const QString &title, const Theme &theme, QWidget *parent = nullptr); + + void set_expanded(bool new_state) override; + +protected: + /// Draws the single card the header and rows sit on. + void paintEvent(QPaintEvent *event) override; + +private: + const Theme *theme_ = nullptr; + ClipBox *clip_ = nullptr; + QVariantAnimation *animation_ = nullptr; + bool first_apply_ = true; + + /** @brief Where the animation is heading. + * + * Explicit rather than read back from the body's visibility, which stays true + * throughout a collapse and would make the finish handler undo the collapse + * it just performed. + */ + bool expanded_ = true; +}; + +/** @brief Section factory matching the industrial rows. + * + * `theme` must outlive every section the factory builds. Pass one owned by the + * ThemeRegistry. + */ +SectionFactory make_section_factory(const Theme &theme); + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp new file mode 100644 index 0000000..2c52c12 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp @@ -0,0 +1,64 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include +#include +#include + +#include "meta_qt/ui/theme.hpp" + +class QPainter; + +namespace meta::qt::industrial +{ + +/** @brief Geometry of one label / rail / value-field row. + * + * Shared so the float and int sliders cannot drift apart. Every measurement + * keys off the row's own width, never the window's. + */ +struct SliderGeometry +{ + QRect label; ///< label column, left + QRect rail; ///< recessed well the fill and thumb sit in + QRect fill; ///< accent portion of the rail, left of the thumb + QRect thumb; ///< machined handle + QRect field; ///< value readout, right + + static SliderGeometry compute(const Theme &theme, + int width, + int height, + qreal norm); +}; + +/// What the shared painter needs to know about the row's current state. +struct SliderVisual +{ + QString label; + std::string category; ///< selects the group accent for the rail fill + bool modified = false; + bool locked = false; +}; + +/** @brief Paint label, rail well, accent fill and thumb. + * + * The value field is a real QLineEdit owned by the control, so it is not + * painted here, only measured. + * + * Elision is the caller's job because it needs the control's tooltip; pass a + * label that already fits. + */ +void paint_slider_row(QPainter &painter, + const Theme &theme, + const SliderGeometry &geometry, + const SliderVisual &visual, + int height); + +/// Stylesheet for the value field, following the theme and row state. +QString field_stylesheet(const Theme &theme, + bool editing, + bool modified, + bool locked); + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/stock/stock.hpp b/MetaUI/qt/include/meta_qt/designs/stock/stock.hpp new file mode 100644 index 0000000..ff9a2d7 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/stock/stock.hpp @@ -0,0 +1,29 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once + +namespace meta::qt::stock +{ + +/// Design name to pass to render_row() / DesignRegistry. +inline constexpr char kDesignName[] = "stock"; + +/** @brief Register the built-in widgets as a design. + * + * Idempotent. + * + * "stock" is a flavour like any other, at the same level as "industrial" -- + * not a privileged fallback baked into the dispatcher. Each entry is a thin + * wrapper around the existing WidgetRenderer, registered under the wildcard + * widget_type because WidgetRenderer already resolves widget_type itself. + * No stock widget is rewritten and WidgetRenderer stays a public + * compile-time extension point. + * + * Registering these is what lets DesignRegistry be the only dispatch: with + * stock present as data, render_row() no longer needs a hardcoded call to + * qt::render(), and the typeid if-chain behind it becomes dead weight. + */ +void register_design(); + +} // namespace meta::qt::stock diff --git a/MetaUI/qt/include/meta_qt/ui/binding.hpp b/MetaUI/qt/include/meta_qt/ui/binding.hpp new file mode 100644 index 0000000..c72ea66 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/ui/binding.hpp @@ -0,0 +1,141 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include + +#include + +#include "meta_common.hpp" + +#include "meta_qt/meta_widget.hpp" +#include "meta_qt/ui/control.hpp" + +namespace meta::qt +{ + +/** @brief Equality used to decide whether a value differs from its default. + * + * Specialise for types where exact comparison is wrong. Floating point uses the + * 1e-4 tolerance the state matrix is defined in terms of. + */ +template struct ValueCompare +{ + static bool equal(const T &a, const T &b) { return a == b; } +}; + +template <> struct ValueCompare +{ + static bool equal(float a, float b) { return std::fabs(a - b) <= 1e-4f; } +}; + +template <> struct ValueCompare +{ + static bool equal(double a, double b) { return std::fabs(a - b) <= 1e-4; } +}; + +/** @brief Wire an attribute to a control, both directions. + * + * Named bind_control rather than bind: an unqualified bind() call with a std + * type such as std::string pulls std::bind in through ADL and fails somewhere + * deep inside , nowhere near the actual mistake. + * + * Written once per *type* and never per design. Every visual variant of a float + * control shares this function, so the races below are fixed in one place: + * + * - A model sync arriving mid-drag re-seats the value under the cursor and + * drops the handle. Suppressed via Control::is_editing(). + * - Writing the attribute notifies subscribers synchronously, which syncs back + * into the control, which would re-emit. Broken with QSignalBlocker. + * - The subscription outliving the widget dereferences freed memory. It is + * stored in MetaWidget::connection_, declared so it dies first. + * + * The control owns range clamping and metadata reading; this function knows + * neither, which is what keeps it type-generic. + * + * Note this does *not* decide when the host recomputes. It emits + * edit_started/value_changed/edit_ended on `host` and the host chooses which to + * act on -- that is where a live-update setting belongs. + */ +template +void bind_control(Attribute &attr, Control &control, MetaWidget &host) +{ + const std::string key = attr.name(); + + // --- modified state, recomputed on every value change + auto refresh_modified = [&attr, &control, key]() + { + const auto &provider = control.context().default_value; + if (!provider) + { + control.set_modified(false); + return; + } + + const std::any def = provider(key); + if (!def.has_value()) + { + control.set_modified(false); + return; + } + + try + { + control.set_modified( + !ValueCompare::equal(attr.value(), std::any_cast(def))); + } + catch (const std::bad_any_cast &) + { + control.set_modified(false); + } + }; + + // --- control -> model + QObject::connect(&control, + &ControlBase::edit_started, + &host, + [&host]() { Q_EMIT host.edit_started(); }); + + QObject::connect(&control, + &ControlBase::value_changed, + &host, + [&attr, &control, &host, refresh_modified]() + { + attr.set_from_any(control.get()); + refresh_modified(); + Q_EMIT host.value_changed(); + }); + + QObject::connect(&control, + &ControlBase::edit_ended, + &host, + [&attr, &control, &host, refresh_modified]() + { + attr.set_from_any(control.get()); + refresh_modified(); + Q_EMIT host.edit_ended(); + }); + + // --- model -> control + host.set_sync_from_model( + [&attr, &control, refresh_modified]() + { + // A sync must never fight an edit in progress. + if (control.is_editing()) return; + + const QSignalBlocker blocker(&control); + control.set(attr.value()); + refresh_modified(); + }); + + // Dies with the widget: connection_ is declared before anything it captures. + host.connection_ = attr.value_changed.subscribe( + [&host](const T &) { host.sync_widget_from_model(); }); + + // --- initial state + control.set_locked( + meta::common::try_get(attr, meta::keys::ui::read_only, false)); + refresh_modified(); +} + +} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/ui/control.hpp b/MetaUI/qt/include/meta_qt/ui/control.hpp new file mode 100644 index 0000000..0ae032f --- /dev/null +++ b/MetaUI/qt/include/meta_qt/ui/control.hpp @@ -0,0 +1,136 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include +#include +#include + +#include + +#include "meta_qt/ui/theme.hpp" + +class QWheelEvent; + +namespace meta::qt +{ + +/** @brief What a control is handed at construction. + * + * Everything a control needs that is not on the attribute itself. Kept as a + * struct so adding a field later does not touch every control constructor. + */ +struct RowContext +{ + const Theme *theme = nullptr; + + /** @brief Default value for an attribute key, for the modified/default state. + * + * Supplied by the host, because "modified" means `|value - default| > 1e-4` + * and Meta attributes do not carry their default. An empty std::any (or an + * unset provider) means "unknown", which is reported as *not* modified -- + * a panel that wrongly shows everything as modified is worse than one that + * shows nothing as modified. + */ + std::function default_value; +}; + +/** @brief Non-templated base for every attribute control. + * + * Carries the parts that cannot live in a template (Q_OBJECT) and the parts + * every control must get right regardless of design. Two behaviours are + * enforced here rather than documented, because both have been re-broken: + * + * - Wheel events are ignored unless the control has focus, so scrolling the + * panel cannot silently change whatever value sits under the cursor. + * wheelEvent() is final; override handle_wheel() instead. + * - is_editing() is set by begin_edit()/end_edit() alongside the matching + * signals, so a control cannot raise one without the other. The binder reads + * it to stop a model sync from re-seating a value mid-drag. + */ +class ControlBase : public QWidget +{ + Q_OBJECT + +public: + explicit ControlBase(const RowContext &ctx, QWidget *parent = nullptr); + + const Theme &theme() const { return *theme_; } + const RowContext &context() const { return ctx_; } + + /// True between begin_edit() and end_edit(); a sync must not fight this. + bool is_editing() const { return editing_; } + + bool is_locked() const { return locked_; } + void set_locked(bool locked); + + /// `|value - default| > 1e-4`. Drives text colour only -- never the fill. + bool is_modified() const { return modified_; } + void set_modified(bool modified); + +signals: + void edit_started(); + void value_changed(); + void edit_ended(); + +protected: + /// Enter the editing state and emit edit_started(). Idempotent. + void begin_edit(); + + /// Leave the editing state and emit edit_ended(). Idempotent. + void end_edit(); + + /// Emit value_changed(). Does not alter the editing state. + void notify_value_changed(); + + /// Called when the state matrix changed; repaint or restyle here. + virtual void on_state_changed() { update(); } + + /** @brief Elide `text` to `width`, surfacing the full text as a tooltip. + * + * Attribute labels routinely overrun the label column. A hard cut is + * indistinguishable from a genuinely short name, so elide and let the + * tooltip carry the rest -- but never shadow a tooltip the host already set + * from ui.tooltip metadata. + */ + QString elide_label(const QString &text, const QFont &font, int width); + + /// Override this rather than wheelEvent(); only called when focused. + virtual void handle_wheel(QWheelEvent *event); + + void wheelEvent(QWheelEvent *event) final; + +private: + RowContext ctx_; + const Theme *theme_ = nullptr; + bool editing_ = false; + bool locked_ = false; + bool modified_ = false; +}; + +/** @brief Typed control interface. + * + * Typed rather than QVariant-based so glm and Meta types need no metatype + * registration, and so a mismatched registration fails to compile instead of + * failing at runtime. + * + * Implementations are constructed as `ControlT(Attribute &, const RowContext + * &, QWidget *)` -- see make_row_factory(). A control reads its own metadata + * (label, range, format, log scale); the binder deliberately knows none of it. + */ +template class Control : public ControlBase +{ +public: + using ControlBase::ControlBase; + + virtual T get() const = 0; + + /** @brief Seat a value coming from the model. + * + * The binder already suppresses this during an edit, so implementations need + * not re-check is_editing(). + */ + virtual void set(const T &value) = 0; +}; + +} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/ui/design_registry.hpp b/MetaUI/qt/include/meta_qt/ui/design_registry.hpp new file mode 100644 index 0000000..2eb17d3 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/ui/design_registry.hpp @@ -0,0 +1,183 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include +#include +#include +#include +#include + +#include + +#include "meta/core/abstract_attribute.hpp" + +#include "meta_qt/meta_widget.hpp" +#include "meta_qt/ui/binding.hpp" +#include "meta_qt/ui/control.hpp" +#include "meta_qt/ui/theme.hpp" +#include "meta_qt/widgets/collapsible_section.hpp" + +namespace meta::qt +{ + +/// Builds the collapsible section used for a category. +using SectionFactory = + std::function; + +/// Builds a fully bound row for one attribute, or nullptr if it cannot. +using RowFactory = std::function< + MetaWidget *(AbstractAttribute &, const RowContext &, QWidget *)>; + +/// Matches any widget_type for a given C++ type. +inline constexpr char kAnyWidgetType[] = "*"; + +/** @brief Construct-and-bind a control of type `ControlT` for attribute type + * `T`. + * + * The uniform control constructor is + * `ControlT(Attribute &, const RowContext &, QWidget *)`. A control reads + * its own metadata; binding is identical for every control of a type and lives + * in bind(). + * + * A control must also provide `static bool can_render(const Attribute &)`. + * Returning false declines the attribute and resolution continues down the + * design's fallback chain -- the required escape hatch for metadata a design + * cannot honour, such as a rail with no min/max to span. Making it part of the + * contract means a control cannot forget to check and silently render a + * [0, 0] range instead. + */ +template RowFactory make_row_factory() +{ + return [](AbstractAttribute &abstract_attr, + const RowContext &ctx, + QWidget *parent) -> MetaWidget * + { + auto &attr = static_cast &>(abstract_attr); + + if (!ControlT::can_render(attr)) return nullptr; + + MetaWidget *host = make_meta_widget_vbox(parent); + auto *control = new ControlT(attr, ctx, host); + + host->layout()->addWidget(control); + bind_control(attr, *control, *host); + + return host; + }; +} + +/** @brief Maps (design, C++ type, widget_type) to a row factory. + * + * This is what makes a visual design a registration rather than an edit to a + * dispatch function. Dispatch used to resolve type and widget_type through two + * hardcoded if-chains, so every new design or type meant editing shared code; + * here they are table entries. + * + * Every design is a flavour at the same level -- "stock" is registered like any + * other (see designs/stock) and holds no special position in the dispatcher. + * + * Resolution for a given design, then repeated down its fallback chain: + * 1. exact (type, widget_type) + * 2. (type, "*") + * 3. otherwise continue with the fallback design, if one is set + * + * A factory returning nullptr counts as a miss, so a control declining an + * attribute via can_render() resumes the same walk. + */ +class DesignRegistry +{ +public: + static DesignRegistry &instance(); + + /// Register a factory. Replaces any existing entry for the same key. + void add(const std::string &design, + std::type_index type, + const std::string &widget_type, + RowFactory factory); + + /** @brief Make `design` fall back to `fallback` for anything it lacks. + * + * Flavours sit at the same level -- there is no privileged design in the + * dispatcher. But a design under construction covers only some widget types, + * and a panel that renders two rows and drops the rest is not usable, so the + * chain is expressed as *data* rather than as a special case in code. + * + * Set "industrial" -> "stock" and an unported widget type renders stock; + * leave it unset and an unregistered type renders nothing. Cycles are + * ignored rather than followed. + */ + void set_fallback(const std::string &design, const std::string &fallback); + + /// Convenience wrapper around add() + make_row_factory(). + template + void register_control(const std::string &design, + const std::string &widget_type) + { + add(design, + std::type_index(typeid(T)), + widget_type, + make_row_factory()); + } + + /** @brief Build a row for `p_attr` using `design`, following its fallbacks. + * + * Returns nullptr when neither `design` nor anything in its fallback chain + * has a factory for this attribute, or when every candidate declined it via + * can_render(). + */ + MetaWidget *render(AbstractAttribute *p_attr, + const std::string &design, + const RowContext &ctx, + QWidget *parent = nullptr) const; + + /// Register a custom section factory for category cards in a design. + void register_section_factory(const std::string &design, + SectionFactory factory); + + /** @brief Look up the section factory for `design`, following its fallback + * chain. + * + * If neither `design` nor any fallback registered a factory, returns a + * default factory constructing a standard CollapsibleSection. + */ + SectionFactory section_factory(const std::string &design) const; + + /// Associate a theme name with a design. + void set_theme(const std::string &design, const std::string &theme_name); + + /// Look up the theme for `design`, following its fallback chain. + const Theme &theme(const std::string &design) const; + + bool has_control(const std::string &design, + std::type_index type, + const std::string &widget_type) const; + + bool has_design(const std::string &design) const; + + /// Registered design names, for a settings UI. + std::vector designs() const; + +private: + DesignRegistry() = default; + + using Key = std::pair; + + std::map> factories_; + std::map fallbacks_; + std::map section_factories_; + std::map themes_; +}; + +/** @brief Render one attribute using `design`. + * + * The single entry point a panel should call. Thin by design: dispatch lives + * entirely in DesignRegistry, fallback chain included, so there is no hardcoded + * renderer sitting behind it. + */ +MetaWidget *render_row(AbstractAttribute *p_attr, + const std::string &design, + const RowContext &ctx, + QWidget *parent = nullptr); + +} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/ui/glide.hpp b/MetaUI/qt/include/meta_qt/ui/glide.hpp new file mode 100644 index 0000000..c692e2f --- /dev/null +++ b/MetaUI/qt/include/meta_qt/ui/glide.hpp @@ -0,0 +1,55 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include +#include + +namespace meta::qt +{ + +/** @brief A retargetable eased value, used wherever a control must not snap. + * + * Exists as a type rather than as a loose QVariantAnimation because two + * mistakes around animated values are easy to make and hard to spot: + * + * - A running QVariantAnimation ignores a new end value. Retargeting without + * stopping first silently does nothing, which reads as "the reset button is + * broken". to() always stops first. + * - Committing a value immediately after starting an animation commits the + * *old* value, because the animation has not produced the new one yet. The + * commit belongs on finished(), which is why that signal carries the value. + */ +class Glide : public QObject +{ + Q_OBJECT + +public: + explicit Glide(int duration_ms, QObject *parent = nullptr); + + /// Animate towards `target`. Safe to call while already running. + void to(qreal target); + + /// Move immediately, cancelling any running animation. Emits tick(), not + /// finished(). + void jump(qreal value); + + qreal current() const { return current_; } + + bool running() const; + + void set_duration(int ms); + +signals: + /// Emitted on every animation frame and on jump(). + void tick(qreal value); + + /// Emitted once the animation settles. Carry commits on this, never earlier. + void finished(qreal value); + +private: + QVariantAnimation animation_; + qreal current_ = 0.0; +}; + +} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/ui/theme.hpp b/MetaUI/qt/include/meta_qt/ui/theme.hpp new file mode 100644 index 0000000..f5fbd61 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/ui/theme.hpp @@ -0,0 +1,252 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include +#include +#include + +#include +#include +#include + +namespace meta::qt +{ + +/** @brief A monospaced font that exists on this platform. + * + * Naming a family directly is a portability trap: `Menlo` is macOS-only and + * elsewhere silently degrades every numeric readout to a proportional face, + * which looks like a layout bug rather than a missing font. The result is + * resolved once and cached. + */ +QFont mono_font(int pixel_size); + +/// The UI sans face. No family override -- the system font is correct here. +QFont ui_font(int pixel_size, bool bold = false, qreal letter_spacing = 0.0); + +/** @brief Geometry and timing constants for a design. + * + * Separate from the palette because a colourway swap changes colours only, + * while a design swap may change both. + */ +struct Metrics +{ + // --- rows + int row_height = 36; + int label_min_width = 90; + int label_max_width = 168; + qreal label_width_ratio = 0.3; + int gap = 12; + int narrow_threshold = + 430; ///< row width below which the narrow branch applies + + // --- value field + int value_field_width = 74; + int value_field_width_narrow = 64; + int value_field_height = 24; + + // --- rail and thumb + int rail_height = 6; + int rail_radius = 1; + int thumb_width = 10; + int thumb_height = 18; + + // --- switch rows + int check_row_height = 28; + int switch_width = 36; + int switch_height = 18; + int knob_size = 12; + int knob_inset = 3; + + // --- section + int section_header_height = 38; + int section_body_padding_x = 20; + int section_body_padding_x_narrow = 12; + int section_body_padding_y = 12; + int section_row_spacing = 10; + int section_card_margin = 14; ///< inset of a card from the panel edge + int section_card_gap = 10; ///< vertical gap between consecutive cards + int section_card_radius = 6; + int row_bar_height = 30; ///< the bar a value row is drawn inside + + // --- shared + int radius = 2; + int glide_ms = 260; ///< value glide; nothing snaps + int switch_ms = 150; ///< switch knob slide + int section_ms = 200; ///< disclosure rotation +}; + +/** @brief A complete colourway plus geometry. + * + * A value type, deliberately: two panels may hold different themes, a theme is + * trivially testable, and swapping one is an assignment rather than a mutation + * of process-wide state. Controls receive a `const Theme &` that outlives them + * (owned by the ThemeRegistry) and read it at paint time. + * + * Derived colours are exposed as *functions* rather than baked swatches, + * because the formula is the thing that must survive an accent change. + * + * The member initialisers below are the reference colourway, kept so a + * default-constructed Theme paints something sane and so the sampled values + * stay documented. They are not the intended source of colour: see + * from_palette(), which is what lets the design sit on top of somebody else's + * palette. + */ +struct Theme +{ + std::string name = "industrial-dark"; + + /** @brief Derive a whole theme from a QPalette. + * + * The base theme has to follow the host application's palette, otherwise the + * design only works against the one colour scheme it was sampled from. + * + * Surfaces blend towards black or white rather than using darker()/lighter() + * on the palette role, because a recessed well is darker and a top bevel is + * lighter in *both* light and dark schemes, whereas darker() on a near-black + * window barely moves. Dimmed ink instead blends towards the window colour, + * so "less prominent text" automatically means darker on a light scheme and + * lighter on a dark one without branching on which we are in. + * + * Accent comes from QPalette::Highlight, so the design picks up the host's + * accent rather than imposing one. Assign over `accent` afterwards to pin it. + * + * Geometry (Metrics) is untouched: it is not a palette concern. + */ + static Theme from_palette(const QPalette &palette, + const std::string &name = "palette"); + + // --- surfaces + QColor page{"#2b2b2b"}; + QColor bar{"#262626"}; + QColor section_surface{ + "#4a4a4a"}; ///< card behind a whole section, header + body + QColor section_header{"#4a4a4a"}; ///< always equal to section_surface + QColor section_header_hover{"#545454"}; + QColor section_header_press{"#444444"}; + QColor rail_well{"#1c1c1c"}; + QColor field{"#1f1f1f"}; + QColor field_hover{"#262626"}; + QColor field_editing{"#161616"}; + QColor switch_track_off{"#1f1f1f"}; + + // --- hairlines and bevels + QColor bevel_top{"#3d3d3d"}; + QColor bevel_bottom{"#232323"}; + QColor hairline{"#1a1a1a"}; + QColor rail_well_border{"#161616"}; + QColor field_border{"#4a4a4a"}; + QColor field_border_hover{"#5a5a5a"}; + + // --- ink. Only text encodes state; see state_ink(). + QColor ink_primary{"#e0e0e0"}; + QColor ink_section_title{"#d0d0d0"}; + QColor ink_secondary{"#9a9a9a"}; ///< value at default + QColor ink_dim{"#8a8a8a"}; + QColor ink_locked{"#606060"}; + QColor ink_modified{"#ffffff"}; + QColor ink_icon{"#c9c9c9"}; + + // --- metal + QColor thumb_top{"#d6d6d6"}; + QColor thumb_bottom{"#a8a8a8"}; + QColor thumb_border{"#1a1a1a"}; + QColor thumb_grip{"#5f5f5f"}; + QColor knob_on_top{"#e8e8e8"}; + QColor knob_on_bottom{"#b8b8b8"}; + QColor knob_off_top{"#8a8a8a"}; + QColor knob_off_bottom{"#6a6a6a"}; + + // --- accent + QColor accent{"#e08a2e"}; + + /** @brief Per-group accents, keyed by attribute category. Falls back to + * `accent`. + * + * Deliberately not palette-derived: these encode *meaning* (which family of + * operation a parameter belongs to), so they have to stay distinguishable + * from each other rather than track a host accent. from_palette() leaves them + * alone. + */ + std::map group_accents = { + {"Erosion", QColor("#cfa143")}, + {"Downcutting", QColor("#3aa899")}, + {"Scale", QColor("#7d9cc0")}, + {"Flow", QColor("#c06478")}, + {"Selective", QColor("#a08bb8")}, + {"Other", QColor("#9a9a9a")}}; + + // --- derived-colour opacities. Port the formula, not the swatch. + qreal rail_fill_alpha = 0.9; + qreal locked_rail_fill_alpha = 0.3; + qreal locked_thumb_alpha = 0.4; + qreal switch_track_on_darker = 1.7; + + Metrics metrics; + + // --- derived colours + + /// Accent for an attribute category, falling back to the chrome accent. + QColor group_accent(const std::string &category) const; + + /// Rail fill: always the group accent, never a state colour. + QColor rail_fill(const std::string &category, bool locked = false) const; + + /// Switch track when on. + QColor switch_track_on() const; + + /** @brief The one colour state is allowed to change. + * + * @param modified `|value - default| > 1e-4`, not "changed since opened". + */ + QColor state_ink(bool modified, bool locked) const; +}; + +/** @brief Owns the built-in themes and any registered by a host. + * + * Themes are resolved once, when a panel is built. Switching requires a + * restart: controls cache brushes and pixmaps derived from the theme, and + * invalidating those on every change would cost more than the feature is worth. + * + * The default is "palette", derived from the application palette via + * Theme::from_palette(). It is built lazily because the registry is a static + * singleton and may well be constructed before QApplication exists, and cached + * afterwards, so a palette change mid-session is not picked up (again: + * restart). + */ +class ThemeRegistry +{ +public: + /// Derived from the application palette. The default. + static constexpr char kPaletteTheme[] = "palette"; + + static ThemeRegistry &instance(); + + /// Register a theme under `theme.name`, replacing any existing entry. + void add(Theme theme); + + /** @brief Look up a theme by name. + * + * Returns the default theme when `name` is unknown, so a stale settings file + * degrades to something usable rather than to an unpainted panel. + */ + const Theme &get(const std::string &name) const; + + /// Names of every registered theme, for a settings UI. + std::vector names() const; + + /// The theme returned when a lookup misses. + const Theme &fallback() const; + +private: + ThemeRegistry(); + + /// Build and cache the palette-derived theme on first use. + void ensure_palette_theme() const; + + mutable std::map themes_; + std::string fallback_name_; +}; + +} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer.hpp b/MetaUI/qt/include/meta_qt/widget_renderer.hpp index 3fef618..e7218f7 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer.hpp +++ b/MetaUI/qt/include/meta_qt/widget_renderer.hpp @@ -14,64 +14,23 @@ namespace meta::qt { -/// Fallback widget renderer for unsupported types. -template struct WidgetRenderer -{ - /// Render an attribute of unsupported type as an error widget. - static MetaWidget *render(Attribute &attr, QWidget *parent) - { - std::string msg; - msg.reserve(128); - msg += "Unsupported type: "; - msg += TypeName::name; - msg += ", "; - msg += attr.name(); - - MetaWidget *widget = make_meta_widget_hbox(parent); - auto *layout = widget->layout(); - - QLabel *label = new QLabel(msg.c_str(), widget); - layout->addWidget(label); - - return widget; - } -}; +/// Render a runtime-typed attribute into a MetaWidget using the default design. +MetaWidget *render(AbstractAttribute *p_attr, QWidget *parent = nullptr); /// Helper: render a typed attribute into a MetaWidget. template MetaWidget *render(Attribute &attr, QWidget *parent = nullptr) { - return WidgetRenderer::render(attr, parent); + return render(&attr, parent); } -/// Render a runtime-typed attribute into a MetaWidget. -MetaWidget *render(AbstractAttribute *p_attr, QWidget *parent = nullptr); - -} // namespace meta::qt - -/// /!\ also update widget_renderer.cpp - -#include "meta_qt/widget_renderer_inl/bool.inl" -#include "meta_qt/widget_renderer_inl/float.inl" -#include "meta_qt/widget_renderer_inl/int.inl" - -#include "meta_qt/widget_renderer_inl/std_filesystem_path.inl" -#include "meta_qt/widget_renderer_inl/std_string.inl" -#include "meta_qt/widget_renderer_inl/std_vector_float.inl" - -#ifdef META_ENABLE_GLM_TYPES -#include "meta_qt/widget_renderer_inl/glm_ivec2.inl" -#include "meta_qt/widget_renderer_inl/glm_vec2.inl" -#include "meta_qt/widget_renderer_inl/glm_vec3.inl" -#include "meta_qt/widget_renderer_inl/glm_vec4.inl" - -#include "meta_qt/widget_renderer_inl/std_vector_glm_vec3.inl" -#endif - -#ifdef META_ENABLE_COLOR_GRADIENT_TYPES -#include "meta_qt/widget_renderer_inl/color_gradient.inl" -#endif +/// Compatibility wrapper forwarding to meta::qt::render(). +template struct WidgetRenderer +{ + static MetaWidget *render(Attribute &attr, QWidget *parent = nullptr) + { + return meta::qt::render(&attr, parent); + } +}; -#ifdef META_ENABLE_ARRAY_TYPES -#include "meta_qt/widget_renderer_inl/array.inl" -#endif \ No newline at end of file +} // namespace meta::qt \ No newline at end of file diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/array.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/array.inl deleted file mode 100644 index 16bb762..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/array.inl +++ /dev/null @@ -1,282 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once - -#include -#include -#include -#include -#include - -#include "meta_common.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/array_canvas.hpp" -#include "meta_qt/widgets/points_canvas.hpp" // For ImageData definition - -#include "meta/ext/array/array.hpp" -#include "meta/metadata/keys.hpp" - -namespace meta::qt -{ - -// ===================================== -// Resampling Helpers -// ===================================== - -inline std::vector resample_bilinear_array(const std::vector &src, - int src_w, - int src_h, - int dst_w, - int dst_h) -{ - if (src.empty() || src_w <= 0 || src_h <= 0) - return std::vector(static_cast(dst_w * dst_h), 0.f); - - std::vector dst(static_cast(dst_w * dst_h)); - const float x_scale = static_cast(src_w) / static_cast(dst_w); - const float y_scale = static_cast(src_h) / static_cast(dst_h); - - for (int j = 0; j < dst_h; ++j) - { - for (int i = 0; i < dst_w; ++i) - { - const float sx = (static_cast(i) + 0.5f) * x_scale - 0.5f; - const float sy = (static_cast(j) + 0.5f) * y_scale - 0.5f; - - const int x0 = std::clamp(static_cast(std::floor(sx)), 0, src_w - 1); - const int x1 = std::clamp(x0 + 1, 0, src_w - 1); - const int y0 = std::clamp(static_cast(std::floor(sy)), 0, src_h - 1); - const int y1 = std::clamp(y0 + 1, 0, src_h - 1); - - const float tx = sx - std::floor(sx); - const float ty = sy - std::floor(sy); - - const float v00 = src[static_cast(y0 * src_w + x0)]; - const float v10 = src[static_cast(y0 * src_w + x1)]; - const float v01 = src[static_cast(y1 * src_w + x0)]; - const float v11 = src[static_cast(y1 * src_w + x1)]; - - dst[static_cast(j * dst_w + i)] = (1.f - ty) * ((1.f - tx) * v00 + - tx * v10) + - ty * ((1.f - tx) * v01 + - tx * v11); - } - } - return dst; -} - -inline float cubic_hermite_array(float a, float b, float c, float d, float t) -{ - const float A = -0.5f * a + 1.5f * b - 1.5f * c + 0.5f * d; - const float B = a - 2.5f * b + 2.0f * c - 0.5f * d; - const float C = -0.5f * a + 0.5f * c; - const float D = b; - return ((A * t + B) * t + C) * t + D; -} - -inline std::vector resample_bicubic_array(const std::vector &src, - int src_w, - int src_h, - int dst_w, - int dst_h) -{ - if (src.empty() || src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0) - return std::vector(static_cast(dst_w * dst_h), 0.f); - - auto at = [&](int x, int y) -> float - { - x = std::clamp(x, 0, src_w - 1); - y = std::clamp(y, 0, src_h - 1); - return src[static_cast(y * src_w + x)]; - }; - - std::vector dst(static_cast(dst_w * dst_h)); - const float x_scale = static_cast(src_w) / static_cast(dst_w); - const float y_scale = static_cast(src_h) / static_cast(dst_h); - - for (int j = 0; j < dst_h; ++j) - { - for (int i = 0; i < dst_w; ++i) - { - const float sx = (static_cast(i) + 0.5f) * x_scale - 0.5f; - const float sy = (static_cast(j) + 0.5f) * y_scale - 0.5f; - - const int x0 = static_cast(std::floor(sx)); - const int y0 = static_cast(std::floor(sy)); - const float tx = sx - static_cast(x0); - const float ty = sy - static_cast(y0); - - float rows[4]; - for (int r = -1; r <= 2; ++r) - rows[r + 1] = cubic_hermite_array(at(x0 - 1, y0 + r), - at(x0, y0 + r), - at(x0 + 1, y0 + r), - at(x0 + 2, y0 + r), - tx); - - dst[static_cast(j * dst_w + i)] = cubic_hermite_array(rows[0], - rows[1], - rows[2], - rows[3], - ty); - } - } - return dst; -} - -// --------------------------------------------------------------------------- -// WidgetRenderer -// --------------------------------------------------------------------------- - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (widget_type.empty()) widget_type = "ArrayEditor"; - - if (widget_type == "None") - { - return nullptr; - } - else if (widget_type == "ArrayEditor") - { - int canvas_w = 128; - int canvas_h = 128; - - if (const auto *val = attr.metadata().try_value( - meta::keys::ui::width)) - canvas_w = *val; - if (const auto *val = attr.metadata().try_value( - meta::keys::ui::height)) - canvas_h = *val; - - auto *canvas = new ArrayCanvas(label_txt, canvas_w, canvas_h, widget); - layout->addWidget(canvas); - - // Sync logic from model to widget - widget->set_sync_from_model( - [canvas, widget, &attr]() - { - // R1 sync contract: never clobber a live-edited canvas. During a - // paint gesture the model is refreshed FROM the canvas; a host - // recomputing synchronously on value_changed would otherwise - // resample the stale model back over the in-progress stroke. - if (widget->is_editing()) return; - - auto const &arr = attr.value(); - std::vector data = arr.vector; - - // Amplitude semantics: the canvas is a raw [0,1] surface (draw_at - // and colormap both clamp to it), and the model is expected to - // hold values in that same unit range (e.g. Hesiod's Brush node - // treats it as normalized height). Do NOT min/max-stretch the - // model into [0,1] here: that would make canvas and model - // disagree on units, and since live-edit/edit_ended write the - // canvas straight back into the model (no inverse transform), - // every gesture after a sync would re-upload the display- - // stretched amplitude, inflating the painting on each round - // trip. Displaying model values directly keeps canvas and model - // in the same units, so repeated sync/edit cycles are - // amplitude-stable; out-of-range model data simply displays - // saturated (clamped) rather than being silently rescaled. - data = resample_bilinear_array(data, - arr.shape.x, - arr.shape.y, - canvas->get_field_width(), - canvas->get_field_height()); - canvas->set_field_data(data); - }); - - widget->sync_widget_from_model(); - - // Background image DataProvider support - meta::DataProvider data_provider; - if (const auto *mp = attr.metadata().find(meta::keys::ui::data_provider)) - if (const auto - *dp = mp->try_cast>()) - data_provider = dp->value(); - - if (data_provider) - { - try - { - auto data = data_provider(); - if (auto img = data.get()) - if (img->width > 0 && img->height > 0 && !img->pixels.empty()) - canvas->set_background_image(img->pixels, - img->width, - img->height, - img->channels); - } - catch (...) - { - // a faulty host provider must not crash - } - } - - // Live edits - QObject::connect(canvas, - &ArrayCanvas::value_changed, - widget, - [&attr, canvas, widget]() - { - // mark editing FIRST so the sync-from-model callback - // is inert for the rest of the gesture - Q_EMIT widget->edit_started(); - - // canvas -> model BEFORE announcing the change: hosts - // may recompute synchronously on value_changed and - // must see the fresh stroke, not the previous state - auto const &cdata = canvas->get_field_data(); - auto &arr = attr.value(); - arr.vector = resample_bicubic_array( - cdata, - canvas->get_field_width(), - canvas->get_field_height(), - arr.shape.x, - arr.shape.y); - - Q_EMIT widget->value_changed(); - attr.value_changed.notify(attr.value()); - }); - - // Committed edits - QObject::connect(canvas, - &ArrayCanvas::edit_ended, - widget, - [&attr, canvas, widget]() - { - auto const &cdata = canvas->get_field_data(); - auto &arr = attr.value(); - arr.vector = resample_bicubic_array( - cdata, - canvas->get_field_width(), - canvas->get_field_height(), - arr.shape.x, - arr.shape.y); - - Q_EMIT widget->edit_ended(); - attr.value_changed.notify(attr.value()); - }); - } - else - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - widget->connection_ = attr.value_changed.subscribe( - [widget](meta::Array) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/bool.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/bool.inl deleted file mode 100644 index 46a44ef..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/bool.inl +++ /dev/null @@ -1,179 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once -#include -#include -#include -#include -#include - -#include "meta/type/type_name.hpp" -#include "meta_common.hpp" - -#include "meta_qt/meta_widget.hpp" - -namespace meta::qt -{ - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - bool &value = attr.value(); - - MetaWidget *widget = make_meta_widget_grid(parent); - auto *layout = dynamic_cast(widget->layout()); - - if (widget_type.empty()) widget_type = "Toggle"; - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (widget_type == "Toggle") // --- Togglr - { - auto *button = new QPushButton(label_txt.c_str(), widget); - layout->addWidget(button, 0, 0); - - button->setCheckable(true); - - widget->set_sync_from_model([button, &value]() - { button->setChecked(value); }); - - widget->sync_widget_from_model(); - - QObject::connect(button, - &QPushButton::toggled, - widget, - [widget, &attr](bool checked) - { - attr.set_from_any(checked); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else if (widget_type == "BinaryButtons") // --- BinaryButtons - { - int row = 0; - - if (!label_txt.empty()) - { - QLabel *label = new QLabel(label_txt.c_str(), widget); - layout->addWidget(label, row, 0, 1, 2); - row++; - } - - const std::string label_true = meta::common::try_get( - attr, - meta::keys::ui::label_true, - "True"); - const std::string label_false = meta::common::try_get( - attr, - meta::keys::ui::label_false, - "False"); - - auto *button_true = new QPushButton(QObject::tr(label_true.c_str()), - widget); - auto *button_false = new QPushButton(QObject::tr(label_false.c_str()), - widget); - - layout->addWidget(button_true, row, 0); - layout->addWidget(button_false, row, 1); - - // make the buttons checkable - button_true->setCheckable(true); - button_false->setCheckable(true); - - // set the initial state of the buttons based on the attribute value - widget->set_sync_from_model( - [button_true, button_false, &value]() - { - button_true->setChecked(value); - button_false->setChecked(!value); - }); - - widget->sync_widget_from_model(); - - // connect the buttons' clicked signals to update the state - QObject::connect(button_true, - &QPushButton::clicked, - widget, - [widget, button_true, button_false, &attr]() - { - if (button_true->isChecked()) - { - button_false->setChecked(false); - attr.set_from_any(true); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - } - else - { - // ensure at least one button is always checked - button_true->setChecked(true); - } - }); - - QObject::connect(button_false, - &QPushButton::clicked, - widget, - [widget, button_true, button_false, &attr]() - { - if (button_false->isChecked()) - { - button_true->setChecked(false); - attr.set_from_any(false); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - } - else - { - button_false->setChecked(true); - } - }); - } - else if (widget_type == "Checkbox") // --- Checkbox - { - QCheckBox *checkbox = new QCheckBox(label_txt.c_str(), widget); - layout->addWidget(checkbox, 0, 0); - - widget->set_sync_from_model([checkbox, &value]() - { checkbox->setChecked(value); }); - - widget->sync_widget_from_model(); - - checkbox->connect(checkbox, - &QCheckBox::toggled, - checkbox, - [widget, checkbox, &attr](bool checked) - { - attr.set_from_any(checked); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget), - 0, - 0); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](bool) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/color_gradient.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/color_gradient.inl deleted file mode 100644 index 2cbe612..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/color_gradient.inl +++ /dev/null @@ -1,106 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once - -#include -#include - -#include "meta_common.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/gradient_picker.hpp" - -#include "meta/ext/color_gradient/color_gradient.hpp" -#include "meta/metadata/keys.hpp" - -namespace meta::qt -{ - -// --------------------------------------------------------------------------- -// WidgetRenderer -// -// widget_type: "GradientEditor" (default) -// -// Stops come from the attribute value; presets come from the ui.presets -// metadata entry (GradientPresets), installed by the host at setup time. -// --------------------------------------------------------------------------- - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, - QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - - ColorGradient &cga = attr.value(); - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (widget_type.empty()) widget_type = "GradientEditor"; - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (widget_type == "GradientEditor") // --- GradientEditor - { - if (!label_txt.empty()) - layout->addWidget( - new QLabel(QString::fromStdString(label_txt), widget)); - - const auto *p_presets = attr.metadata().try_value( - meta::keys::ui::presets); - - auto *picker = new GradientPicker(cga.value(), - p_presets ? p_presets->presets - : std::vector{}, - widget); - layout->addWidget(picker); - - widget->set_sync_from_model( - [picker]() - { - picker->update_bar(); - picker->update(); - }); - - // Live edits - QObject::connect(picker, - &GradientPicker::value_changed, - widget, - [&attr, widget]() - { - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - - attr.value_changed.notify(attr.value()); - }); - - // Committed - QObject::connect(picker, - &GradientPicker::edit_ended, - widget, - [&attr, widget]() - { - Q_EMIT widget->edit_ended(); - attr.value_changed.notify(attr.value()); - }); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](meta::ColorGradient) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/float.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/float.inl deleted file mode 100644 index c2a6ad2..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/float.inl +++ /dev/null @@ -1,223 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once -#include - -#include -#include -#include -#include -#include -#include - -#include "meta/type/type_name.hpp" -#include "meta_common.hpp" - -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/slider_float.hpp" - -namespace meta::qt -{ - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - const std::string format = meta::common::format(attr); - const float min = meta::common::min(attr); - const float max = meta::common::max(attr); - const float step = meta::common::step(attr); - const bool plus_minus = meta::common::try_get(attr, - "ui.plus_minus", - false); - const bool log_scale = meta::common::try_get( - attr, - meta::keys::ui::log_scale, - false); - - float &value = attr.value(); - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (!label_txt.empty() && widget_type != "SliderFloat") - { - QLabel *label = new QLabel(label_txt.c_str(), widget); - layout->addWidget(label); - } - - if (widget_type.empty()) widget_type = "Input"; - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (widget_type == "Input") // --- Input - { - // --- INPUT - - auto *spinbox = new QDoubleSpinBox(widget); - - spinbox->setMinimum(min); - spinbox->setMaximum(max); - spinbox->setSingleStep(step); - spinbox->setValue(std::clamp(value, min, max)); - spinbox->setDecimals(meta::common::try_get_format_decimals(format)); - - layout->addWidget(spinbox); - - widget->set_sync_from_model( - [spinbox, &value]() - { - const QSignalBlocker blocker(spinbox); - spinbox->setValue(value); - }); - - QObject::connect(spinbox, - &QDoubleSpinBox::valueChanged, - spinbox, - [&attr, widget, min, max](double v) - { - attr.set_from_any( - std::clamp(static_cast(v), min, max)); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else if (widget_type == "Slider" || widget_type == "ScrollBar" || - widget_type == "Dial") // --- Sliders - { - if (!attr.metadata().contains_all_keys( - {meta::keys::constraints::min, meta::keys::constraints::max})) - { - layout->addWidget(make_error_widget(&attr, "missing metadata", widget)); - return widget; - } - - constexpr int range_min = 0; - constexpr int range_max = 1000; - - auto to_int = [min, max](float v) -> int - { return static_cast(((v - min) / (max - min)) * range_max); }; - - auto from_int = [min, max](int v) -> float - { return min + (static_cast(v) / range_max) * (max - min); }; - - attr.set_from_any(std::clamp(value, min, max)); - - QAbstractSlider *control = nullptr; - - if (widget_type == "Slider") - { - auto *slider = new QSlider(Qt::Horizontal, widget); - slider->setRange(range_min, range_max); - slider->setValue(to_int(value)); - control = slider; - } - else if (widget_type == "ScrollBar") - { - auto *scrollbar = new QScrollBar(Qt::Horizontal, widget); - scrollbar->setRange(range_min, range_max); - scrollbar->setValue(to_int(value)); - control = scrollbar; - } - else if (widget_type == "Dial") - { - auto *dial = new QDial(widget); - dial->setRange(range_min, range_max); - dial->setValue(to_int(value)); - control = dial; - } - - widget->set_sync_from_model( - [control, &value, min, max]() - { - auto to_int = [min, max](float v) -> int - { return static_cast(((v - min) / (max - min)) * 1000); }; - - const QSignalBlocker blocker(control); - control->setValue(to_int(value)); - }); - - QObject::connect(control, - &QAbstractSlider::sliderPressed, - widget, - [widget]() { Q_EMIT widget->edit_started(); }); - - QObject::connect(control, - &QAbstractSlider::valueChanged, - widget, - [&attr, widget, from_int, min, max](int v) - { - attr.set_from_any(std::clamp(from_int(v), min, max)); - Q_EMIT widget->value_changed(); - }); - - QObject::connect(control, - &QAbstractSlider::sliderReleased, - widget, - [widget]() { Q_EMIT widget->edit_ended(); }); - - layout->addWidget(control); - } - else if (widget_type == "SliderFloat") // --- SliderFloat - { - auto *slider = new SliderFloat(label_txt, - value, - min, - max, - plus_minus, - format, - log_scale, - widget); - slider->set_value(value); - layout->addWidget(slider); - - widget->set_sync_from_model( - [slider, &value]() - { - const QSignalBlocker blocker(slider); - slider->set_value(value); - }); - - // Live drag / +- buttons - QObject::connect(slider, - &SliderFloat::value_changed, - widget, - [&attr, slider, widget]() - { - attr.set_from_any(slider->get_value()); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - }); - - // Committed (drag release, double-click confirm, context menu action) - QObject::connect(slider, - &SliderFloat::edit_ended, - widget, - [&attr, slider, widget]() - { - attr.set_from_any(slider->get_value()); - Q_EMIT widget->edit_ended(); - }); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](float) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_ivec2.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_ivec2.inl deleted file mode 100644 index f063b73..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_ivec2.inl +++ /dev/null @@ -1,204 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once -#include "meta_qt/widgets/power_of_two_spin_box.hpp" - -namespace meta::qt -{ - -inline int ceil_power_of_two(int v) -{ - if (v <= 1) return 1; - - int p = 1; - - while (p < v) - p <<= 1; - - return p; -} - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - const int min = meta::common::min(attr); - const int max = meta::common::max(attr); - const int step = meta::common::step(attr); - const bool power_of_two = meta::common::power_of_two(attr); - const float aspect_ratio = meta::common::aspect_ratio(attr); - const bool keep_aspect = (aspect_ratio != 0.f); - - glm::ivec2 &value = attr.value(); - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (widget_type.empty()) widget_type = "Input"; - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (widget_type == "Input") // --- Input - { - if (!label_txt.empty()) - { - layout->addWidget( - new QLabel(QString::fromStdString(label_txt), widget)); - } - - auto *row = new QHBoxLayout(); - - QSpinBox *spinbox_x = power_of_two ? new PowerOfTwoSpinBox(widget) - : new QSpinBox(widget); - - QSpinBox *spinbox_y = power_of_two ? new PowerOfTwoSpinBox(widget) - : new QSpinBox(widget); - - for (auto *sp : {spinbox_x, spinbox_y}) - { - sp->setRange(min, max); - sp->setSingleStep(step); - } - - spinbox_x->setValue(std::clamp(value.x, min, max)); - spinbox_y->setValue(std::clamp(value.y, min, max)); - - if (keep_aspect) - { - spinbox_y->setEnabled(false); - spinbox_y->setRange(int(min / aspect_ratio), int(max / aspect_ratio)); - spinbox_y->setToolTip( - QString(QObject::tr("Aspect ratio x/y = %1")).arg(aspect_ratio)); - - // synchronize the initial value - int y = int(std::lround(double(spinbox_x->value()) / aspect_ratio)); - value.x = spinbox_x->value(); - value.y = y; - - spinbox_y->setValue(y); - } - - row->addWidget(spinbox_x); - row->addWidget(spinbox_y); - - layout->addLayout(row); - - widget->set_sync_from_model( - [spinbox_x, - spinbox_y, - &value, - min, - max, - power_of_two, - keep_aspect, - aspect_ratio]() - { - int x = std::clamp(value.x, min, max); - int y = std::clamp(value.y, min, max); - - if (power_of_two) - { - x = ceil_power_of_two(x); - y = ceil_power_of_two(y); - } - - if (keep_aspect) y = int(std::lround(double(x) / aspect_ratio)); - - { - QSignalBlocker blocker(spinbox_x); - spinbox_x->setValue(x); - } - - { - QSignalBlocker blocker(spinbox_y); - spinbox_y->setValue(y); - } - }); - - QObject::connect(spinbox_x, - qOverload(&QSpinBox::valueChanged), - widget, - [&value, - &attr, - widget, - spinbox_x, - spinbox_y, - min, - max, - power_of_two, - keep_aspect, - aspect_ratio](int v) - { - int x = std::clamp(v, min, max); - - if (power_of_two) x = ceil_power_of_two(x); - - { - QSignalBlocker blocker(spinbox_x); - spinbox_x->setValue(x); - } - - glm::ivec2 new_value = {x, 0}; - - if (keep_aspect) - { - int y = int(std::lround(double(x) / aspect_ratio)); - new_value.y = y; - - QSignalBlocker blocker(spinbox_y); - spinbox_y->setValue(y); - } - - attr.set_from_any(new_value); - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - - if (!keep_aspect) - { - QObject::connect( - spinbox_y, - qOverload(&QSpinBox::valueChanged), - widget, - [&value, &attr, widget, spinbox_y, min, max, power_of_two](int v) - { - int y = std::clamp(v, min, max); - - if (power_of_two) y = ceil_power_of_two(y); - - { - QSignalBlocker blocker(spinbox_y); - spinbox_y->setValue(y); - } - - attr.set_from_any(glm::ivec2{value.x, y}); - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](glm::ivec2) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec2.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec2.inl deleted file mode 100644 index 8527f78..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec2.inl +++ /dev/null @@ -1,865 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once -#include - -#include - -#include "meta/core/data_provider.hpp" -#include "meta_qt/widgets/range_bar.hpp" -#include "meta_qt/widgets/responsive_box.hpp" -#include "meta_qt/widgets/vector_canvas.hpp" -#include "meta_qt/widgets/xy_canvas.hpp" - -namespace meta::qt -{ - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - const std::string format = meta::common::format(attr); - const float min = meta::common::min(attr); - const float max = meta::common::max(attr); - const float step = meta::common::step(attr); - const bool show_grid = meta::common::try_get(attr, - "ui.show_grid", - true); - - const std::string x_label = meta::common::try_get(attr, - "ui.label_x", - "x"); - const std::string y_label = meta::common::try_get(attr, - "ui.label_y", - "y"); - - const int decimals = meta::common::try_get_format_decimals(format); - - // --- UI state management - - // either add with current input state 'locked_xy' or override - // current 'locked_xy' with state - bool locked_xy = false; - if (const auto *p = attr.state().try_value( - meta::keys::state::locked_xy)) - locked_xy = *p; - - // --- Generate widget - - glm::vec2 &value = attr.value(); - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (widget_type.empty()) widget_type = "Input"; - - if (!label_txt.empty()) - layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (widget_type == "Input") // --- Input - { - auto *row = new QHBoxLayout(); - - auto *spinbox_x = new QDoubleSpinBox(widget); - auto *spinbox_y = new QDoubleSpinBox(widget); - - for (auto *sp : {spinbox_x, spinbox_y}) - { - sp->setRange(min, max); - sp->setSingleStep(step); - sp->setDecimals(decimals); - } - - spinbox_x->setValue(std::clamp(value.x, min, max)); - spinbox_y->setValue(std::clamp(value.y, min, max)); - - row->addWidget(spinbox_x); - row->addWidget(spinbox_y); - - layout->addLayout(row); - - widget->set_sync_from_model( - [&value, spinbox_x, spinbox_y, min, max]() - { - { - QSignalBlocker b(spinbox_x); - spinbox_x->setValue(std::clamp(value.x, min, max)); - } - - { - QSignalBlocker b(spinbox_y); - spinbox_y->setValue(std::clamp(value.y, min, max)); - } - }); - - QObject::connect(spinbox_x, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [&attr, &value, widget, spinbox_x, min, max](double v) - { - float x = std::clamp(static_cast(v), min, max); - - { - QSignalBlocker blocker(spinbox_x); - spinbox_x->setValue(x); - } - - attr.set_from_any(glm::vec2{x, value.y}); - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - - QObject::connect(spinbox_y, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [&attr, &value, widget, spinbox_y, min, max](double v) - { - float y = std::clamp(static_cast(v), min, max); - - { - QSignalBlocker blocker(spinbox_y); - spinbox_y->setValue(y); - } - - attr.set_from_any(glm::vec2{value.x, y}); - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else if (widget_type == "XYCanvas") // --- XYCanvas - { - // Per-axis bounds (compat "xy()" preset stash), falling back to the - // shared min/max when absent so asymmetric domains (e.g. x in [0,1], - // y in [0,100]) reach the widget. - const float min_x = meta::common::try_get(attr, - meta::keys::ui::min_x, - min); - const float max_x = meta::common::try_get(attr, - meta::keys::ui::max_x, - max); - const float min_y = meta::common::try_get(attr, - meta::keys::ui::min_y, - min); - const float max_y = meta::common::try_get(attr, - meta::keys::ui::max_y, - max); - - auto *canvas = - new XYCanvas(value, min_x, max_x, min_y, max_y, show_grid, widget); - layout->addWidget(canvas); - - // Button row - auto *btn_row = new QHBoxLayout(); - auto *center_btn = new QPushButton(QObject::tr("Center"), widget); - auto *random_btn = new QPushButton(QObject::tr("Random"), widget); - - for (auto *btn : {center_btn, random_btn}) - { - btn->setFixedHeight(22); - btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - btn_row->addWidget(btn); - } - layout->addLayout(btn_row); - - widget->set_sync_from_model( - [&value, canvas]() - { - QSignalBlocker blocker(canvas); - canvas->set_value(value); - }); - - // Fires on every drag step — edit_started + value_changed only. - QObject::connect(canvas, - &XYCanvas::value_changed, - widget, - [widget, &attr](glm::vec2) - { - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - // Fires once on mouse release — edit_ended. - QObject::connect(canvas, - &XYCanvas::drag_ended, - widget, - [widget, &attr](glm::vec2) - { - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - // Center - QObject::connect(center_btn, - &QPushButton::clicked, - widget, - [&attr, min, max, canvas, widget]() - { - const glm::vec2 center = {(min + max) * 0.5f, - (min + max) * 0.5f}; - attr.set_from_any(center); - canvas->set_value(center); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - - // Random - QObject::connect(random_btn, - &QPushButton::clicked, - widget, - [&attr, min, max, canvas, widget]() - { - static std::mt19937 rng{std::random_device{}()}; - std::uniform_real_distribution dist_x(min, max); - std::uniform_real_distribution dist_y(min, max); - const glm::vec2 rv = {dist_x(rng), dist_y(rng)}; - attr.set_from_any(rv); - canvas->set_value(rv); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else if (widget_type == "RangeBar") // --- RangeBar - { - bool is_active = true; - if (const auto *p = attr.state().try_value( - meta::keys::state::active)) - is_active = *p; - - // Restore the last meaningful range when toggling back on. - // Seeded from the current value if it's valid, otherwise full domain. - glm::vec2 last_active_value = is_active ? value : glm::vec2{min, max}; - - auto *bar = new RangeBar(value, min, max, decimals, widget); - - meta::DataProvider range_provider; // empty if none - if (const auto *mp = attr.metadata().find(meta::keys::ui::data_provider)) - if (const auto - *dp = mp->try_cast>()) - range_provider = dp->value(); - - if (range_provider) - { - try - { - auto data = range_provider(); - // An empty result is forwarded too: the bar shows a "no data" hint - // instead of drawing nothing. - if (auto histogram = data.get()) - bar->set_histogram(histogram->x, histogram->y); - else - bar->set_histogram({}, {}); - } - catch (...) - { - // a faulty host provider must not crash the panel - } - } - - auto *btn_row = new QHBoxLayout(); - auto *toggle_btn = new QPushButton(widget); - auto *reset_btn = new QPushButton(QObject::tr("Full"), widget); - auto *center_btn = new QPushButton(QObject::tr("Center"), widget); - auto *unit_btn = new QPushButton(QObject::tr("[0, 1]"), widget); - - toggle_btn->setCheckable(true); - toggle_btn->setChecked(is_active); - toggle_btn->setText(is_active ? QObject::tr("On") : QObject::tr("Off")); - toggle_btn->setFixedHeight(22); - toggle_btn->setFixedWidth(40); - - for (auto *btn : {reset_btn, center_btn, unit_btn}) - { - btn->setFixedHeight(22); - btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - } - - btn_row->addWidget(toggle_btn); - btn_row->addSpacing(4); - btn_row->addWidget(reset_btn); - btn_row->addWidget(center_btn); - btn_row->addWidget(unit_btn); - - layout->addWidget(bar); - layout->addLayout(btn_row); - - // Enable or disable all range controls according to the sentinel state. - // Buttons that cannot change the current value stay disabled: "Full" - // when the range already spans the whole domain, "Center" when the - // span covers the domain and cannot be shifted. - auto set_active = - [&value, bar, reset_btn, center_btn, unit_btn, min, max](bool active) - { - const bool full_domain = value.x <= min && value.y >= max; - bar->setEnabled(active); - reset_btn->setEnabled(active && !full_domain); - center_btn->setEnabled(active && value.y - value.x < max - min); - unit_btn->setEnabled(active); - }; - - set_active(is_active); - - widget->set_sync_from_model( - [&value, &attr, bar, toggle_btn, set_active, widget, range_provider]() - { - bool active = true; - if (const auto *p = attr.state().try_value( - meta::keys::state::active)) - active = *p; - - set_active(active); - - { - QSignalBlocker b(toggle_btn); - toggle_btn->setChecked(active); - toggle_btn->setText(active ? QObject::tr("On") - : QObject::tr("Off")); - } - - { - QSignalBlocker b(bar); - bar->set_value(value); - bar->update(); - } - - if (range_provider && !widget->is_editing()) - { - try - { - auto data = range_provider(); - if (auto histogram = data.get()) - bar->set_histogram(histogram->x, histogram->y); - else - bar->set_histogram({}, {}); - } - catch (...) - { - } - } - }); - - // Toggle - QObject::connect(toggle_btn, - &QPushButton::toggled, - widget, - [&value, - &attr, - bar, - toggle_btn, - set_active, - widget, - // Mutable copy so each lambda invocation can update it. - lav = last_active_value](bool active) mutable - { - toggle_btn->setText(active ? QObject::tr("On") - : QObject::tr("Off")); - - if (active) - { - // Restore last known good range. - attr.set_from_any(lav); - bar->set_value(lav); - } - else - { - // Save current range before clobbering it. - lav = value; - attr.set_from_any(glm::vec2{-1.f, 0.f}); - bar->set_value({-1.f, 0.f}); - } - - // update state - if (auto *p = attr.state().try_value( - meta::keys::state::active)) - *p = active; - - // After the value update so button states reflect it. - set_active(active); - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - - // Live drag → edit_started + value_changed - QObject::connect(bar, - &RangeBar::value_changed, - widget, - [widget, set_active](glm::vec2) - { - set_active(true); // refresh button states - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - }); - - // Release → edit_ended - QObject::connect(bar, - &RangeBar::drag_ended, - widget, - [widget](glm::vec2) { Q_EMIT widget->edit_ended(); }); - - // Full domain - QObject::connect(reset_btn, - &QPushButton::clicked, - widget, - [&attr, min, max, bar, widget, set_active]() - { - bar->set_value({min, max}); - attr.set_from_any(glm::vec2{min, max}); - set_active(true); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - - // Center — shift span to domain midpoint - QObject::connect( - center_btn, - &QPushButton::clicked, - widget, - [&value, &attr, min, max, bar, widget, set_active]() - { - const float span = value.y - value.x; - const float mid = (min + max) * 0.5f; - const float lo = std::clamp(mid - span * 0.5f, min, max - span); - bar->set_value({lo, lo + span}); - attr.set_from_any(glm::vec2{lo, lo + span}); - set_active(true); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - - // Unit — [0, 1] clamped to domain - QObject::connect(unit_btn, - &QPushButton::clicked, - widget, - [&attr, min, max, bar, widget, set_active]() - { - const float lo = std::clamp(0.f, min, max); - const float hi = std::clamp(1.f, min, max); - bar->set_value({lo, hi}); - attr.set_from_any(glm::vec2{lo, hi}); - set_active(true); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else if (widget_type == "VectorEditor") // --- VectorEditor - { - // --- Canvas - - auto *canvas = new VectorCanvas(value, max, locked_xy, widget); - - // Centre the fixed-size canvas horizontally. - auto *canvas_row = new QHBoxLayout(); - canvas_row->addStretch(); - canvas_row->addWidget(canvas); - canvas_row->addStretch(); - layout->addLayout(canvas_row); - - // --- Controls - - auto *form = new QFormLayout(); - form->setContentsMargins(0, 2, 0, 2); - form->setSpacing(3); - - // Magnitude spinbox - auto *mag_spin = new QDoubleSpinBox(widget); - mag_spin->setRange(0.0, double(max)); - mag_spin->setDecimals(decimals); - mag_spin->setSingleStep(double(max) / 100.0); - mag_spin->setValue(double(canvas->magnitude())); - mag_spin->setFixedHeight(22); - - // Angle spinbox (disabled when locked) - auto *angle_spin = new QDoubleSpinBox(widget); - angle_spin->setRange(-360.0, 360.0); - angle_spin->setDecimals(1); - angle_spin->setSingleStep(1.0); - angle_spin->setSuffix("°"); - angle_spin->setValue(double(canvas->angle_deg())); - angle_spin->setEnabled(locked_xy); - angle_spin->setFixedHeight(22); - - form->addRow(QObject::tr("Magnitude"), mag_spin); - form->addRow(QObject::tr("Angle"), angle_spin); - layout->addLayout(form); - - // Lock toggle - auto *lock_row = new QHBoxLayout(); - auto *lock_cb = new QCheckBox(QObject::tr("Isotropic (kx = ky)"), - widget); - lock_cb->setChecked(locked_xy); - lock_row->addStretch(); - lock_row->addWidget(lock_cb); - layout->addLayout(lock_row); - - // --- Sync helpers - - widget->set_sync_from_model( - [&attr, &value, canvas, mag_spin, angle_spin, lock_cb]() - { - float mag = glm::length(value); - float deg = (mag > 1e-6f) - ? std::atan2(value.y, value.x) * 180.f / float(M_PI) - : 45.f; - bool stored_locked_state = false; - if (const auto *p = attr.state().try_value( - meta::keys::state::locked_xy)) - stored_locked_state = *p; - - { - QSignalBlocker b(canvas); - canvas->set_locked(stored_locked_state); - canvas->set_magnitude(mag); - canvas->set_angle_deg(deg); - } - - { - QSignalBlocker b(mag_spin); - mag_spin->setValue(mag); - } - - { - QSignalBlocker b(angle_spin); - angle_spin->setValue(deg); - angle_spin->setEnabled(!stored_locked_state); - } - - { - QSignalBlocker b(lock_cb); - lock_cb->setChecked(stored_locked_state); - } - }); - - // Canvas → spinboxes (keep in sync after drag) - QObject::connect(canvas, - &VectorCanvas::magnitude_changed, - widget, - [&attr, mag_spin](float mag) - { - QSignalBlocker b(mag_spin); - mag_spin->setValue(double(mag)); - }); - - QObject::connect(canvas, - &VectorCanvas::angle_changed, - widget, - [&attr, angle_spin](float deg) - { - QSignalBlocker b(angle_spin); - angle_spin->setValue(double(deg)); - }); - - // Magnitude spinbox → canvas - QObject::connect(mag_spin, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [canvas](double v) { canvas->set_magnitude(float(v)); }); - - // Angle spinbox → canvas - QObject::connect(angle_spin, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [canvas](double v) { canvas->set_angle_deg(float(v)); }); - - // Lock toggle → canvas + angle spinbox enable state - QObject::connect(lock_cb, - &QCheckBox::toggled, - widget, - [&attr, canvas, angle_spin](bool checked) - { - canvas->set_locked(checked); - angle_spin->setEnabled(!checked); - - attr.state() - .try_add(meta::keys::state::locked_xy, checked) - ->value() = checked; - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - // --- Graph signals - - // Live drag / spinbox change - QObject::connect(canvas, - &VectorCanvas::value_changed, - widget, - [&attr, widget](glm::vec2) - { - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - // Committed (drag release, lock toggle, spinbox enter) - QObject::connect(canvas, - &VectorCanvas::drag_ended, - widget, - [&attr, widget](glm::vec2) - { - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - // Spinboxes commit on editingFinished (Return / focus-out) - auto spinbox_commit = [&attr, widget]() - { - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }; - - QObject::connect(mag_spin, - &QDoubleSpinBox::editingFinished, - widget, - spinbox_commit); - QObject::connect(angle_spin, - &QDoubleSpinBox::editingFinished, - widget, - spinbox_commit); - - // Lock toggle commits immediately - QObject::connect(lock_cb, - &QCheckBox::toggled, - widget, - [&attr, widget](bool) - { - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - } - else if (widget_type == "LinkedSliders") // --- LinkedSliders - { - // Row: [slider X] [slider Y] [X=Y toggle] - auto *row = new QHBoxLayout(); - row->setSpacing(4); - - auto *slider_x = new SliderFloat(x_label, - value.x, - min, - max, - /* plus_minus */ false, - format, - /* log_scale */ false, - widget); - auto *slider_y = new SliderFloat(y_label, - value.y, - min, - max, - /* plus_minus */ false, - format, - /* log_scale */ false, - widget); - - slider_x->set_value(value.x); - slider_y->set_value(value.y); - - // Lock toggle — compact, fixed width so sliders get most of the space - auto *lock_btn = new QPushButton(QObject::tr("X=Y"), widget); - lock_btn->setCheckable(true); - lock_btn->setChecked(locked_xy); - lock_btn->setFixedWidth(36); - lock_btn->setFixedHeight(slider_x->sizeHint().height()); - lock_btn->setToolTip(QObject::tr("Lock X = Y")); - - // Visual feedback: bold/highlighted when locked - auto update_lock_style = [lock_btn](bool locked) - { - lock_btn->setProperty("locked", locked); - // Simple style: invert background when active - lock_btn->setStyleSheet(locked ? "font-weight: bold;" - : "font-weight: normal;"); - }; - update_lock_style(locked_xy); - - // Sync Y → X when locked on startup - if (locked_xy) - { - value.y = value.x; - slider_y->set_value(value.x); - slider_y->setEnabled(false); - } - - // The slider pair lives in a ResponsiveBox: side-by-side when there is - // room, stacked vertically when the panel is too narrow to fit both. - // The box reports a one-slider minimum width, so the panel is free to - // narrow below the two-slider width (which is what triggers stacking). - auto *pair = new ResponsiveBox(widget); - pair->set_spacing(4); - pair->add_widget(slider_x, 1); // stretch factor 1 - pair->add_widget(slider_y, 1); - - row->addWidget(pair, 1); - row->addWidget(lock_btn, 0); - layout->addLayout(row); - - // --- Connections - - widget->set_sync_from_model( - [&attr, widget_type, &value, slider_x, slider_y, lock_btn]() - { - { - QSignalBlocker b(slider_x); - slider_x->set_value(value.x); - } - - { - QSignalBlocker b(slider_y); - slider_y->set_value(value.y); - } - - bool stored_locked_state = false; - if (const auto *p = attr.state().try_value( - meta::keys::state::locked_xy)) - stored_locked_state = *p; - else - stored_locked_state = false; - - { - QSignalBlocker b(lock_btn); - lock_btn->setChecked(stored_locked_state); - } - - slider_y->setEnabled(!lock_btn->isChecked()); - }); - - // slider_x changed - QObject::connect(slider_x, - &SliderFloat::value_changed, - widget, - [&attr, &value, slider_x, slider_y, lock_btn, widget]() - { - value.x = slider_x->get_value(); - if (lock_btn->isChecked()) - { - value.y = value.x; - QSignalBlocker b(slider_y); - slider_y->set_value(value.x); - } - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - QObject::connect(slider_x, - &SliderFloat::edit_ended, - widget, - [&attr, &value, slider_x, widget]() - { - value.x = slider_x->get_value(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - // slider_y changed (only reachable when unlocked) - QObject::connect(slider_y, - &SliderFloat::value_changed, - widget, - [&attr, &value, slider_y, widget]() - { - value.y = slider_y->get_value(); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - QObject::connect(slider_y, - &SliderFloat::edit_ended, - widget, - [&attr, &value, slider_y, widget]() - { - value.y = slider_y->get_value(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - // Lock toggle - QObject::connect(lock_btn, - &QPushButton::toggled, - widget, - [&attr, - widget_type, - &value, - slider_x, - slider_y, - lock_btn, - update_lock_style, - widget](bool locked) - { - update_lock_style(locked); - slider_y->setEnabled(!locked); - - if (locked) - { - // Snap Y to current X immediately - value.y = value.x; - { - QSignalBlocker b(slider_y); - slider_y->set_value(value.x); - } - } - - attr.state() - .try_add(meta::keys::state::locked_xy, locked) - ->value() = locked; - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](glm::vec2) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec3.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec3.inl deleted file mode 100644 index 6922b1b..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec3.inl +++ /dev/null @@ -1,267 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once -#include -#include - -namespace meta::qt -{ - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - const std::string format = meta::common::format(attr); - const float min = meta::common::min(attr); - const float max = meta::common::max(attr); - const float step = meta::common::step(attr); - - const int decimals = meta::common::try_get_format_decimals(format); - - glm::vec3 &value = attr.value(); - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (widget_type.empty()) widget_type = "Input"; - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (widget_type == "Input") // --- Input - { - if (!label_txt.empty()) - { - layout->addWidget( - new QLabel(QString::fromStdString(label_txt), widget)); - } - - auto *row = new QHBoxLayout(); - - auto *spinbox_x = new QDoubleSpinBox(widget); - auto *spinbox_y = new QDoubleSpinBox(widget); - auto *spinbox_z = new QDoubleSpinBox(widget); - - for (auto *sp : {spinbox_x, spinbox_y, spinbox_z}) - { - sp->setRange(min, max); - sp->setSingleStep(step); - sp->setDecimals(decimals); - } - - spinbox_x->setValue(std::clamp(value.x, min, max)); - spinbox_y->setValue(std::clamp(value.y, min, max)); - spinbox_z->setValue(std::clamp(value.z, min, max)); - - row->addWidget(spinbox_x); - row->addWidget(spinbox_y); - row->addWidget(spinbox_z); - - layout->addLayout(row); - - widget->set_sync_from_model( - [&value, spinbox_x, spinbox_y, spinbox_z]() - { - { - QSignalBlocker bx(spinbox_x); - spinbox_x->setValue(std::clamp(value.x, 0.f, 1.f)); - } - - { - QSignalBlocker by(spinbox_y); - spinbox_y->setValue(std::clamp(value.y, 0.f, 1.f)); - } - - { - QSignalBlocker bz(spinbox_z); - spinbox_z->setValue(std::clamp(value.z, 0.f, 1.f)); - } - }); - - QObject::connect(spinbox_x, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [&attr, &value, widget, spinbox_x, min, max](double v) - { - float x = std::clamp(static_cast(v), min, max); - - { - QSignalBlocker blocker(spinbox_x); - spinbox_x->setValue(x); - } - - value.x = x; - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - QObject::connect(spinbox_y, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [&attr, &value, widget, spinbox_y, min, max](double v) - { - float y = std::clamp(static_cast(v), min, max); - - { - QSignalBlocker blocker(spinbox_y); - spinbox_y->setValue(y); - } - - value.y = y; - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - QObject::connect(spinbox_z, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [&attr, &value, widget, spinbox_z, min, max](double v) - { - float z = std::clamp(static_cast(v), min, max); - - { - QSignalBlocker blocker(spinbox_z); - spinbox_z->setValue(z); - } - - value.z = z; - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - } - else if (widget_type == "ColorPicker") // --- ColorPicker - { - if (!label_txt.empty()) - { - layout->addWidget( - new QLabel(QString::fromStdString(label_txt), widget)); - } - - auto *row = new QHBoxLayout(); - - // Color swatch button — shows current color and opens QColorDialog on - // click - auto *color_button = new QPushButton(widget); - color_button->setFixedSize(48, 24); - color_button->setFlat(true); - - auto update_button_color = [color_button](const glm::vec3 &v) - { - const int r = static_cast(std::clamp(v.x, 0.f, 1.f) * 255.f); - const int g = static_cast(std::clamp(v.y, 0.f, 1.f) * 255.f); - const int b = static_cast(std::clamp(v.z, 0.f, 1.f) * 255.f); - color_button->setStyleSheet( - QString("background-color: rgb(%1,%2,%3); border: 1px solid gray;") - .arg(r) - .arg(g) - .arg(b)); - }; - - update_button_color(value); - row->addWidget(color_button); - - // Hex label (read-only display) - auto *hex_label = new QLabel(widget); - hex_label->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); - - auto update_hex_label = [hex_label](const glm::vec3 &v) - { - const int r = static_cast(std::clamp(v.x, 0.f, 1.f) * 255.f); - const int g = static_cast(std::clamp(v.y, 0.f, 1.f) * 255.f); - const int b = static_cast(std::clamp(v.z, 0.f, 1.f) * 255.f); - hex_label->setText(QString("#%1%2%3") - .arg(r, 2, 16, QChar('0')) - .arg(g, 2, 16, QChar('0')) - .arg(b, 2, 16, QChar('0')) - .toUpper()); - }; - - update_hex_label(value); - row->addWidget(hex_label); - row->addStretch(); - - layout->addLayout(row); - - widget->set_sync_from_model( - [&value, update_button_color, update_hex_label]() - { - update_button_color(value); - update_hex_label(value); - }); - - QObject::connect(color_button, - &QPushButton::clicked, - widget, - [&attr, - &value, - widget, - color_button, - update_button_color, - update_hex_label]() - { - const int ri = static_cast( - std::clamp(value.x, 0.f, 1.f) * 255.f); - const int gi = static_cast( - std::clamp(value.y, 0.f, 1.f) * 255.f); - const int bi = static_cast( - std::clamp(value.z, 0.f, 1.f) * 255.f); - const QColor initial(ri, gi, bi); - - const QColor picked = QColorDialog::getColor( - initial, - widget, - QString(), - QColorDialog::DontUseNativeDialog); - - if (!picked.isValid()) return; - - value.x = static_cast(picked.redF()); - value.y = static_cast(picked.greenF()); - value.z = static_cast(picked.blueF()); - - update_button_color(value); - update_hex_label(value); - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](glm::vec3) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec4.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec4.inl deleted file mode 100644 index d785e36..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec4.inl +++ /dev/null @@ -1,304 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once -#include -#include - -namespace meta::qt -{ - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - const std::string format = meta::common::format(attr); - const float min = meta::common::min(attr); - const float max = meta::common::max(attr); - const float step = meta::common::step(attr); - - const int decimals = meta::common::try_get_format_decimals(format); - - glm::vec4 &value = attr.value(); - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (widget_type.empty()) widget_type = "Input"; - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (widget_type == "Input") // --- Input - { - if (!label_txt.empty()) - { - layout->addWidget( - new QLabel(QString::fromStdString(label_txt), widget)); - } - - auto *row = new QHBoxLayout(); - - auto *spinbox_x = new QDoubleSpinBox(widget); - auto *spinbox_y = new QDoubleSpinBox(widget); - auto *spinbox_z = new QDoubleSpinBox(widget); - auto *spinbox_w = new QDoubleSpinBox(widget); - - for (auto *sp : {spinbox_x, spinbox_y, spinbox_z, spinbox_w}) - { - sp->setRange(min, max); - sp->setSingleStep(step); - sp->setDecimals(decimals); - } - - spinbox_x->setValue(std::clamp(value.x, min, max)); - spinbox_y->setValue(std::clamp(value.y, min, max)); - spinbox_z->setValue(std::clamp(value.z, min, max)); - spinbox_w->setValue(std::clamp(value.w, min, max)); - - row->addWidget(spinbox_x); - row->addWidget(spinbox_y); - row->addWidget(spinbox_z); - row->addWidget(spinbox_w); - - layout->addLayout(row); - - widget->set_sync_from_model( - [&value, spinbox_x, spinbox_y, spinbox_z, spinbox_w]() - { - { - QSignalBlocker bx(spinbox_x); - spinbox_x->setValue(std::clamp(value.x, 0.f, 1.f)); - } - - { - QSignalBlocker by(spinbox_y); - spinbox_y->setValue(std::clamp(value.y, 0.f, 1.f)); - } - - { - QSignalBlocker bz(spinbox_z); - spinbox_z->setValue(std::clamp(value.z, 0.f, 1.f)); - } - - { - QSignalBlocker bz(spinbox_w); - spinbox_w->setValue(std::clamp(value.w, 0.f, 1.f)); - } - }); - - QObject::connect(spinbox_x, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [&attr, &value, widget, spinbox_x, min, max](double v) - { - float x = std::clamp(static_cast(v), min, max); - - { - QSignalBlocker blocker(spinbox_x); - spinbox_x->setValue(x); - } - - value.x = x; - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - QObject::connect(spinbox_y, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [&attr, &value, widget, spinbox_y, min, max](double v) - { - float y = std::clamp(static_cast(v), min, max); - - { - QSignalBlocker blocker(spinbox_y); - spinbox_y->setValue(y); - } - - value.y = y; - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - QObject::connect(spinbox_z, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [&attr, &value, widget, spinbox_z, min, max](double v) - { - float z = std::clamp(static_cast(v), min, max); - - { - QSignalBlocker blocker(spinbox_z); - spinbox_z->setValue(z); - } - - value.z = z; - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - - QObject::connect(spinbox_w, - qOverload(&QDoubleSpinBox::valueChanged), - widget, - [&attr, &value, widget, spinbox_w, min, max](double v) - { - float w = std::clamp(static_cast(v), min, max); - - { - QSignalBlocker blocker(spinbox_w); - spinbox_w->setValue(w); - } - - value.w = w; - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - } - else if (widget_type == "ColorPicker") // --- ColorPicker - { - if (!label_txt.empty()) - { - layout->addWidget( - new QLabel(QString::fromStdString(label_txt), widget)); - } - - auto *row = new QHBoxLayout(); - - auto *color_button = new QPushButton(widget); - color_button->setFixedSize(48, 24); - color_button->setFlat(true); - - // Checkerboard background visible through transparent swatches. - // Painted as a stylesheet with a QPixmap-backed pattern would require - // subclassing; instead we use a simple two-tone gradient approximation - // that is Good Enough for a swatch at this size. - auto update_button_color = [color_button](const glm::vec4 &v) - { - const int r = static_cast(std::clamp(v.x, 0.f, 1.f) * 255.f); - const int g = static_cast(std::clamp(v.y, 0.f, 1.f) * 255.f); - const int b = static_cast(std::clamp(v.z, 0.f, 1.f) * 255.f); - const int a = static_cast(std::clamp(v.w, 0.f, 1.f) * 255.f); - color_button->setStyleSheet( - QString( - "background-color: rgba(%1,%2,%3,%4); border: 1px solid gray;") - .arg(r) - .arg(g) - .arg(b) - .arg(a)); - }; - - update_button_color(value); - row->addWidget(color_button); - - // Hex label including alpha channel (#RRGGBBAA) - auto *hex_label = new QLabel(widget); - hex_label->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); - - auto update_hex_label = [hex_label](const glm::vec4 &v) - { - const int r = static_cast(std::clamp(v.x, 0.f, 1.f) * 255.f); - const int g = static_cast(std::clamp(v.y, 0.f, 1.f) * 255.f); - const int b = static_cast(std::clamp(v.z, 0.f, 1.f) * 255.f); - const int a = static_cast(std::clamp(v.w, 0.f, 1.f) * 255.f); - hex_label->setText(QString("#%1%2%3%4") - .arg(r, 2, 16, QChar('0')) - .arg(g, 2, 16, QChar('0')) - .arg(b, 2, 16, QChar('0')) - .arg(a, 2, 16, QChar('0')) - .toUpper()); - }; - - update_hex_label(value); - row->addWidget(hex_label); - row->addStretch(); - - layout->addLayout(row); - - widget->set_sync_from_model( - [&value, update_button_color, update_hex_label]() - { - update_button_color(value); - update_hex_label(value); - }); - - QObject::connect( - color_button, - &QPushButton::clicked, - widget, - [&attr, &value, widget, update_button_color, update_hex_label]() - { - const int ri = static_cast(std::clamp(value.x, 0.f, 1.f) * - 255.f); - const int gi = static_cast(std::clamp(value.y, 0.f, 1.f) * - 255.f); - const int bi = static_cast(std::clamp(value.z, 0.f, 1.f) * - 255.f); - const int ai = static_cast(std::clamp(value.w, 0.f, 1.f) * - 255.f); - const QColor initial(ri, gi, bi, ai); - - const QColor picked = QColorDialog::getColor( - initial, - widget, - QString(), - QColorDialog::ShowAlphaChannel | - QColorDialog::DontUseNativeDialog); - - if (!picked.isValid()) return; - - value.x = static_cast(picked.redF()); - value.y = static_cast(picked.greenF()); - value.z = static_cast(picked.blueF()); - value.w = static_cast(picked.alphaF()); - - update_button_color(value); - update_hex_label(value); - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, force emit - attr.value_changed.notify(attr.value()); - }); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](glm::vec4) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/int.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/int.inl deleted file mode 100644 index 084ff2a..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/int.inl +++ /dev/null @@ -1,253 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once -#include -#include - -#include -#include -#include - -#include "meta/type/type_name.hpp" -#include "meta_common.hpp" - -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/slider_int.hpp" - -namespace meta::qt -{ - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - const std::string format = meta::common::format(attr); - const int min = meta::common::min(attr); - const int max = meta::common::max(attr); - const int step = meta::common::step(attr); - const auto items = meta::common::enum_items(attr); - const bool plus_minus = meta::common::try_get(attr, - "ui.plus_minus", - false); - - int &value = attr.value(); - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (!label_txt.empty() && widget_type != "SliderInt") - { - QLabel *label = new QLabel(label_txt.c_str(), widget); - layout->addWidget(label); - } - - if (widget_type.empty()) widget_type = "Input"; - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (widget_type == "Input") - { - // --- INPUT - - auto *spinbox = new QDoubleSpinBox(widget); - - spinbox->setMinimum(min); - spinbox->setMaximum(max); - spinbox->setSingleStep(step); - spinbox->setDecimals(0); - - // A deserialized value can sit outside the current constraints (e.g. - // constraints tightened in a later version); make the model agree with - // the clamped value that is displayed instead of keeping the stale one. - const int clamped = std::clamp(value, min, max); - spinbox->setValue(clamped); - if (clamped != value) attr.set_from_any(clamped); - - layout->addWidget(spinbox); - - widget->set_sync_from_model( - [spinbox, &value]() - { - const QSignalBlocker blocker(spinbox); - spinbox->setValue(value); - }); - - QObject::connect(spinbox, - &QDoubleSpinBox::valueChanged, - spinbox, - [&attr, widget, min, max](double v) - { - // Round explicitly: an implicit double -> int - // conversion truncates toward zero. - const int iv = static_cast(std::lround(v)); - attr.set_from_any(std::clamp(iv, min, max)); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else if (widget_type == "EnumComboBox") - { - // --- ENUM COMBO BOX - - auto *combo = new QComboBox(widget); - layout->addWidget(combo); - - int current_index = 0; - int index = 0; - - for (const auto &[val, name] : items) - { - combo->addItem(QString::fromStdString(name), QVariant::fromValue(val)); - - if (val == value) current_index = index; - - ++index; - } - - combo->setCurrentIndex(current_index); - - widget->set_sync_from_model( - [combo, &value]() - { - const QSignalBlocker blocker(combo); - - for (int i = 0; i < combo->count(); ++i) - { - if (combo->itemData(i).toInt() == value) - { - combo->setCurrentIndex(i); - break; - } - } - }); - - QObject::connect(combo, - QOverload::of(&QComboBox::currentIndexChanged), - widget, - [&attr, widget, combo](int) - { - attr.set_from_any(combo->currentData().toInt()); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else if (widget_type == "Slider" || widget_type == "ScrollBar" || - widget_type == "Dial") - { - if (!attr.metadata().contains_all_keys( - {meta::keys::constraints::min, meta::keys::constraints::max})) - { - layout->addWidget(make_error_widget(&attr, "missing metadata", widget)); - return widget; - } - - attr.set_from_any(std::clamp(value, min, max)); - - QAbstractSlider *control = nullptr; - - if (widget_type == "Slider") - { - auto *slider = new QSlider(Qt::Horizontal, widget); - slider->setRange(min, max); - slider->setValue(value); - control = slider; - } - else if (widget_type == "ScrollBar") - { - auto *scrollbar = new QScrollBar(Qt::Horizontal, widget); - scrollbar->setRange(min, max); - scrollbar->setValue(value); - control = scrollbar; - } - else if (widget_type == "Dial") - { - auto *dial = new QDial(widget); - dial->setRange(min, max); - dial->setValue(value); - control = dial; - } - - widget->set_sync_from_model( - [control, &value]() - { - const QSignalBlocker blocker(control); - control->setValue(value); - }); - - QObject::connect(control, - &QAbstractSlider::sliderPressed, - widget, - [widget]() { Q_EMIT widget->edit_started(); }); - - QObject::connect(control, - &QAbstractSlider::valueChanged, - widget, - [&attr, widget, min, max](int v) - { - attr.set_from_any(std::clamp(v, min, max)); - Q_EMIT widget->value_changed(); - }); - - QObject::connect(control, - &QAbstractSlider::sliderReleased, - widget, - [widget]() { Q_EMIT widget->edit_ended(); }); - - layout->addWidget(control); - } - else if (widget_type == "SliderInt") // SliderInt - { - auto *slider = - new SliderInt(label_txt, value, min, max, plus_minus, format, widget); - slider->set_value(value); - layout->addWidget(slider); - - widget->set_sync_from_model( - [slider, &value]() - { - const QSignalBlocker blocker(slider); - slider->set_value(value); - }); - - QObject::connect(slider, - &SliderInt::value_changed, - widget, - [&attr, slider, widget]() - { - attr.set_from_any(slider->get_value()); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - }); - - QObject::connect(slider, - &SliderInt::edit_ended, - widget, - [&attr, slider, widget]() - { - attr.set_from_any(slider->get_value()); - Q_EMIT widget->edit_ended(); - }); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](int) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_filesystem_path.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_filesystem_path.inl deleted file mode 100644 index 488ea04..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_filesystem_path.inl +++ /dev/null @@ -1,171 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once -#include - -#include -#include -#include - -namespace meta::qt -{ - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, - QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - const std::string filter = meta::common::file_filter(attr); - const std::string start_dir_meta = meta::common::try_get( - attr, - "ui.start_dir", - ""); - - std::filesystem::path &value = attr.value(); - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (widget_type.empty()) widget_type = "OpenFile"; - - const bool is_open_file = (widget_type == "OpenFile"); - const bool is_save_file = (widget_type == "SaveFile"); - const bool is_directory = (widget_type == "Directory"); - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (is_open_file || is_save_file || is_directory) // --- Others... - { - if (!label_txt.empty()) - { - layout->addWidget( - new QLabel(QString::fromStdString(label_txt), widget)); - } - - // --- path display (read-only line edit) + browse button --- - - auto *row = new QHBoxLayout(); - - auto *line_edit = new QLineEdit(widget); - line_edit->setReadOnly(true); - line_edit->setPlaceholderText(is_directory - ? QObject::tr("No folder selected") - : QObject::tr("No file selected")); - line_edit->setText(QString::fromStdString(value.string())); - - widget->set_sync_from_model( - [line_edit, &value]() - { - const QSignalBlocker blocker(line_edit); - line_edit->setText(QString::fromStdString(value.string())); - }); - - auto *browse_button = new QPushButton(is_directory ? QObject::tr("…") - : QObject::tr("…"), - widget); - browse_button->setFixedWidth(28); - - auto *clear_button = new QPushButton(QObject::tr("✕"), widget); - clear_button->setFixedWidth(24); - clear_button->setToolTip(QObject::tr("Clear")); - - row->addWidget(line_edit); - row->addWidget(browse_button); - row->addWidget(clear_button); - - layout->addLayout(row); - - // --- browse --- - - QObject::connect( - browse_button, - &QPushButton::clicked, - widget, - [&value, - &attr, - widget, - line_edit, - is_save_file, - is_directory, - filter_str = QString::fromStdString(filter), - meta_dir = QString::fromStdString(start_dir_meta)]() - { - // Priority: metadata start_dir → current value's parent → home - const QString start_dir = - !meta_dir.isEmpty() - ? meta_dir - : (!value.empty() ? QString::fromStdString( - value.parent_path().string()) - : QDir::homePath()); - - QString picked; - - if (is_directory) - { - picked = QFileDialog::getExistingDirectory( - widget, - QObject::tr("Select folder"), - start_dir, - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - } - else if (is_save_file) - { - picked = QFileDialog::getSaveFileName(widget, - QObject::tr("Save file"), - start_dir, - filter_str); - } - else // OpenFile - { - picked = QFileDialog::getOpenFileName(widget, - QObject::tr("Open file"), - start_dir, - filter_str); - } - - if (picked.isEmpty()) return; - - attr.set_from_any(std::filesystem::path(picked.toStdString())); - line_edit->setText(picked); - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - - // --- clear --- - - QObject::connect(clear_button, - &QPushButton::clicked, - widget, - [&attr, widget, line_edit]() - { - attr.set_from_any(std::filesystem::path{}); - line_edit->clear(); - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](std::filesystem::path) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_string.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_string.inl deleted file mode 100644 index c989898..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_string.inl +++ /dev/null @@ -1,410 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "meta/type/type_name.hpp" -#include "meta_common.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/helpers.hpp" - -namespace meta::qt -{ - -namespace helpers -{ - -// plain_text_height lives in meta_qt/widgets/helpers.hpp (included above). -// It must NOT be redefined here: helpers.cpp defines it non-inline, and a -// second inline definition is an ODR violation that GNU ld resolves silently -// but MSVC rejects outright (LNK2005, COMDAT vs non-COMDAT). - -// apply min/max height constraints to a QPlainTextEdit from metadata. -inline void apply_height_constraints(QPlainTextEdit *te, - Attribute &attr, - int default_min, - int default_max) -{ - const int min_lines = meta::common::try_get(attr, - "ui.min_lines", - default_min); - const int max_lines = meta::common::try_get(attr, - "ui.max_lines", - default_max); - - te->setMinimumHeight(plain_text_height(te, min_lines)); - te->setMaximumHeight(plain_text_height(te, max_lines)); -} - -// create a small right-aligned "Apply" button row. Returns -// {row_layout, button} - caller adds the row to the parent layout. -inline std::pair make_apply_button( - QWidget *parent) -{ - auto *apply_btn = new QPushButton(QObject::tr("Apply"), parent); - apply_btn->setFixedHeight(22); - apply_btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - - auto *btn_row = new QHBoxLayout(); - btn_row->addStretch(); - btn_row->addWidget(apply_btn); - - return {btn_row, apply_btn}; -} - -} // namespace helpers - -template <> struct WidgetRenderer -{ - static MetaWidget *render(Attribute &attr, QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - const std::vector options = meta::common::allowed_values(attr); - std::string &value = attr.value(); - - // default widget type: ComboBox when choices exist, - // SingleLineText otherwise. - if (widget_type.empty()) - widget_type = options.empty() ? "SingleLineText" : "ComboBox"; - - // multi-line widgets need the label on top → VBoxLayout. - // Single-line widgets keep the label inline → HBoxLayout (matches - // existing style). - const bool needs_vbox = (widget_type == "MultilineText" || - widget_type == "CodeEditor"); - - MetaWidget *widget = needs_vbox ? make_meta_widget_vbox(parent) - : make_meta_widget_hbox(parent); - - // retrieve the layout as QBoxLayout so we can call - // addWidget/addLayout without casting twice. - auto *layout = static_cast(widget->layout()); - - if (!label_txt.empty()) - layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (widget_type == "ReadOnlyText") // --- ReadOnlyText - { - auto *val_label = new QLabel(QString::fromStdString(value), widget); - val_label->setTextInteractionFlags(Qt::TextSelectableByMouse); - layout->addWidget(val_label); - - widget->set_sync_from_model( - [val_label, &value]() - { val_label->setText(QString::fromStdString(value)); }); - } - else if (widget_type == "SingleLineText") // --- SingleLineText - { - const std::string placeholder = meta::common::try_get( - attr, - "ui.placeholder", - std::string{}); - const int max_length = meta::common::try_get(attr, - "ui.max_length", - 0); - - auto *line_edit = new QLineEdit(widget); - line_edit->setText(QString::fromStdString(value)); - - if (!placeholder.empty()) - line_edit->setPlaceholderText(QString::fromStdString(placeholder)); - - if (max_length > 0) line_edit->setMaxLength(max_length); - - // line edit and Apply button share one row. - auto *apply_btn = new QPushButton(QObject::tr("Apply"), widget); - apply_btn->setFixedHeight(22); - apply_btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - - auto *row = new QHBoxLayout(); - row->addWidget(line_edit); - row->addWidget(apply_btn); - layout->addLayout(row); - - widget->set_sync_from_model( - [line_edit, &value]() - { - const QSignalBlocker blocker(line_edit); - line_edit->setText(QString::fromStdString(value)); - }); - - // stage edits; commit only on Apply / Return. - auto do_apply = [&attr, line_edit, widget]() - { - attr.set_from_any(line_edit->text().toStdString()); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }; - - QObject::connect(apply_btn, &QPushButton::clicked, widget, do_apply); - QObject::connect(line_edit, &QLineEdit::returnPressed, widget, do_apply); - } - else if (widget_type == "MultilineText") // --- MultilineText - { - const std::string placeholder = meta::common::try_get( - attr, - "ui.placeholder", - std::string{}); - - auto *text_edit = new QPlainTextEdit(widget); - text_edit->setPlainText(QString::fromStdString(value)); - - if (!placeholder.empty()) - text_edit->setPlaceholderText(QString::fromStdString(placeholder)); - - helpers::apply_height_constraints(text_edit, attr, 4, 12); - - layout->addWidget(text_edit); - - auto [btn_row, apply_btn] = helpers::make_apply_button(widget); - layout->addLayout(btn_row); - - widget->set_sync_from_model( - [text_edit, &value]() - { - const QSignalBlocker blocker(text_edit); - text_edit->setPlainText(QString::fromStdString(value)); - }); - - QObject::connect(apply_btn, - &QPushButton::clicked, - widget, - [&attr, text_edit, widget]() - { - attr.set_from_any( - text_edit->toPlainText().toStdString()); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else if (widget_type == "CodeEditor") // --- CodeEditor - { - const std::string placeholder = meta::common::try_get( - attr, - "ui.placeholder", - std::string{}); - const int tab_width = meta::common::try_get(attr, "ui.tab_width", 4); - - auto *text_edit = new QPlainTextEdit(widget); - - // fixed-pitch font, prefer common coding fonts, fall back to - // system fixed - QFont code_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - for (const char *name : {"JetBrains Mono", - "Fira Code", - "Cascadia Code", - "Consolas", - "DejaVu Sans Mono", - "Courier New"}) - { - QFont f(name); - if (QFontInfo(f).fixedPitch()) - { - code_font = f; - break; - } - } - code_font.setPointSize(9); - text_edit->setFont(code_font); - - // tab stop in pixels = tab_width * advance width of one space. - const int space_width = QFontMetrics(code_font).horizontalAdvance( - QLatin1Char(' ')); - text_edit->setTabStopDistance(tab_width * space_width); - - text_edit->setLineWrapMode(QPlainTextEdit::NoWrap); - text_edit->setPlainText(QString::fromStdString(value)); - - if (!placeholder.empty()) - text_edit->setPlaceholderText(QString::fromStdString(placeholder)); - - helpers::apply_height_constraints(text_edit, attr, 6, 24); - - layout->addWidget(text_edit); - - auto [btn_row, apply_btn] = helpers::make_apply_button(widget); - layout->addLayout(btn_row); - - widget->set_sync_from_model( - [text_edit, &value]() - { - const QSignalBlocker blocker(text_edit); - text_edit->setPlainText(QString::fromStdString(value)); - }); - - QObject::connect(apply_btn, - &QPushButton::clicked, - widget, - [&attr, text_edit, widget]() - { - attr.set_from_any( - text_edit->toPlainText().toStdString()); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else if (widget_type == "ComboBox") // --- - { - auto *combo = new QComboBox(widget); - layout->addWidget(combo); - - int current_index = -1; - for (size_t i = 0; i < options.size(); ++i) - { - combo->addItem(QString::fromStdString(options[i])); - if (options[i] == value) current_index = static_cast(i); - } - if (current_index >= 0) combo->setCurrentIndex(current_index); - - widget->set_sync_from_model( - [&attr, combo, &value]() - { - const QSignalBlocker blocker(combo); - - // Re-pull the allowed-values list: dynamic sources (e.g. the - // Receive node's tag list) can change without a panel rebuild. - const std::vector current_options = - meta::common::allowed_values(attr); - - bool items_differ = combo->count() != - static_cast(current_options.size()); - if (!items_differ) - { - for (int i = 0; i < combo->count(); ++i) - { - if (combo->itemText(i).toStdString() != current_options[i]) - { - items_differ = true; - break; - } - } - } - - if (items_differ) - { - combo->clear(); - for (const auto &opt : current_options) - combo->addItem(QString::fromStdString(opt)); - } - - const QString v = QString::fromStdString(value); - - for (int i = 0; i < combo->count(); ++i) - { - if (combo->itemText(i) == v) - { - combo->setCurrentIndex(i); - return; - } - } - }); - - QObject::connect(combo, - &QComboBox::currentTextChanged, - widget, - [&attr, widget](const QString &text) - { - attr.set_from_any(text.toStdString()); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else if (widget_type == "ButtonGrid") // --- ButtonGrid - { - int max_cols = 5; - if (attr.metadata().contains("ui.columns")) - { - auto *c = attr.metadata().find("ui.columns"); - max_cols = std::any_cast(c->to_any()); - } - - const int n = static_cast(options.size()); - int ncols = std::min(max_cols, static_cast(std::ceil(std::sqrt(n)))); - - auto *grid = new QGridLayout(); - auto *group = new QButtonGroup(widget); - - bool exclusive = true; - if (attr.metadata().contains("ui.exclusive")) - { - auto *m2 = attr.metadata().find("ui.exclusive"); - exclusive = std::any_cast(m2->to_any()); - } - group->setExclusive(exclusive); - - for (int i = 0; i < n; ++i) - { - const std::string &choice = options[i]; - auto *btn = new QPushButton(QString::fromStdString(choice), widget); - btn->setCheckable(true); - btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - if (choice == value) btn->setChecked(true); - group->addButton(btn); - grid->addWidget(btn, i / ncols, i % ncols); - } - - widget->set_sync_from_model( - [group, &value]() - { - const QSignalBlocker blocker(group); - - const QString v = QString::fromStdString(value); - - for (auto *button : group->buttons()) - { - if (button->text() == v) - { - button->setChecked(true); - return; - } - } - }); - - QObject::connect( - group, - QOverload::of(&QButtonGroup::buttonClicked), - widget, - [&attr, widget](QAbstractButton *button) - { - attr.set_from_any(button->text().toStdString()); - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - - layout->addLayout(grid); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](std::string) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_vector_float.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_vector_float.inl deleted file mode 100644 index 4cd583f..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_vector_float.inl +++ /dev/null @@ -1,150 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once -#include -#include -#include - -#include "meta/logger.hpp" - -#include "meta_common.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/curve_canvas.hpp" - -namespace meta::qt -{ - -template <> struct WidgetRenderer> -{ - static MetaWidget *render(Attribute> &attr, - QWidget *parent) - { - std::vector &value = attr.value(); - const int default_size = value.size() ? value.size() : 16; - - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - - const int curve_size = meta::common::try_get(attr, - "ui.curve_size", - default_size); - const float min_x = meta::common::try_get(attr, - meta::keys::ui::min_x, - 0.f); - const float max_x = meta::common::try_get(attr, - meta::keys::ui::max_x, - 1.f); - const float min_y = meta::common::try_get(attr, - meta::keys::ui::min_y, - 0.f); - const float max_y = meta::common::try_get(attr, - meta::keys::ui::max_y, - 1.f); - - if (widget_type.empty()) widget_type = "CurveEditor"; - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (!label_txt.empty()) - layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (widget_type == "CurveEditor") // --- CurveEditor - { - auto *canvas = new CurveCanvas(value, - curve_size, - min_x, - max_x, - min_y, - max_y, - widget); - layout->addWidget(canvas); - - // reset button - auto *btn_row = new QHBoxLayout(); - auto *reset_btn = new QPushButton(QObject::tr("Reset"), widget); - reset_btn->setFixedHeight(22); - reset_btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - btn_row->addStretch(); - btn_row->addWidget(reset_btn); - layout->addLayout(btn_row); - - widget->set_sync_from_model( - [canvas]() - { - const QSignalBlocker blocker(canvas); - canvas->update(); - }); - - // propagate canvas changes to the node graph. - QObject::connect(canvas, - &CurveCanvas::curve_changed, - widget, - [widget, &attr]() - { - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - - // not using 'set_from_any' method, emit the - // signal manually - attr.value_changed.notify(attr.value()); - }); - - QObject::connect(canvas, - &CurveCanvas::drag_ended, - widget, - [widget, &attr]() - { - Q_EMIT widget->edit_ended(); - - // not using 'set_from_any' method, emit the signal - // manually - attr.value_changed.notify(attr.value()); - }); - - // reset to identity diagonal. - QObject::connect(reset_btn, - &QPushButton::clicked, - widget, - [&attr, curve_size, min_y, max_y, canvas, widget]() - { - std::vector new_value; - new_value.reserve(curve_size); - - for (int i = 0; i < curve_size; ++i) - { - const float t = float(i) / float(curve_size - 1); - new_value.push_back(min_y + t * (max_y - min_y)); - } - - attr.set_from_any(new_value); - - // re-create the canvas state from the new buffer. - canvas->reset_to_value(); - - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - Q_EMIT widget->edit_ended(); - }); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](std::vector) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_vector_glm_vec3.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_vector_glm_vec3.inl deleted file mode 100644 index d0bc198..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_vector_glm_vec3.inl +++ /dev/null @@ -1,227 +0,0 @@ -/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General - Public License. The full license is in the file LICENSE, distributed with - this software. */ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include - -#include "meta/core/data_provider.hpp" -#include "meta_common.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/points_canvas.hpp" - -namespace meta::qt -{ - -template <> struct WidgetRenderer> -{ - static MetaWidget *render(Attribute> &attr, - QWidget *parent) - { - std::string widget_type = meta::common::widget_type(attr); - const std::string label_txt = meta::common::label(attr); - - const float min_x = meta::common::try_get(attr, - meta::keys::ui::min_x, - 0.f); - const float max_x = meta::common::try_get(attr, - meta::keys::ui::max_x, - 1.f); - const float min_y = meta::common::try_get(attr, - meta::keys::ui::min_y, - 0.f); - const float max_y = meta::common::try_get(attr, - meta::keys::ui::max_y, - 1.f); - const float z_step = meta::common::try_get(attr, "ui.z_step", 0.05f); - const bool closed = meta::common::try_get(attr, - meta::keys::ui::closed, - false); - - std::vector &value = attr.value(); - - MetaWidget *widget = make_meta_widget_vbox(parent); - auto *layout = static_cast(widget->layout()); - - if (widget_type.empty()) widget_type = "PointsEditor"; - - const bool is_points = (widget_type == "PointsEditor"); - const bool is_path = (widget_type == "PathEditor"); - - if (widget_type == "None") // --- None - { - return nullptr; - } - else if (is_points || is_path) // --- Point and Path editor - { - if (!label_txt.empty()) - layout->addWidget( - new QLabel(QString::fromStdString(label_txt), widget)); - - auto *canvas = new PointsCanvas(value, - min_x, - max_x, - min_y, - max_y, - z_step, - is_path ? PointsCanvas::Mode::Path - : PointsCanvas::Mode::Points, - closed, - widget); - layout->addWidget(canvas); - - meta::DataProvider points_provider; // empty if none - if (const auto *mp = attr.metadata().find(meta::keys::ui::data_provider)) - if (const auto - *dp = mp->try_cast>()) - points_provider = dp->value(); - - if (points_provider) - { - try - { - auto data = points_provider(); - if (auto img = data.get()) - if (img->width > 0 && img->height > 0 && !img->pixels.empty()) - canvas->set_background_image(img->pixels, - img->width, - img->height, - img->channels); - } - catch (...) - { - // a faulty host provider must not crash the panel - } - } - - // --- Toolbar - - auto *toolbar = new QHBoxLayout(); - - // Clear - auto *clear_btn = new QPushButton(QObject::tr("Clear"), widget); - clear_btn->setFixedHeight(22); - clear_btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - toolbar->addWidget(clear_btn); - - auto *rand_btn = new QPushButton(QObject::tr("Randomize"), widget); - rand_btn->setFixedHeight(22); - rand_btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - toolbar->addWidget(rand_btn); - - // From CSV - auto *csv_btn = new QPushButton(QObject::tr("From CSV…"), widget); - csv_btn->setFixedHeight(22); - csv_btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - toolbar->addWidget(csv_btn); - - layout->addLayout(toolbar); - - // --- Connections - - widget->set_sync_from_model( - [&value, canvas, widget, points_provider]() - { - QSignalBlocker blocker(canvas); - canvas->set_points(value); - - if (points_provider && !widget->is_editing()) - { - try - { - auto data = points_provider(); - if (auto img = data.get()) - if (img->width > 0 && img->height > 0 && !img->pixels.empty()) - canvas->set_background_image(img->pixels, - img->width, - img->height, - img->channels); - } - catch (...) - { - } - } - }); - - // Live edits (add / move / z scroll) → edit_started + value_changed - QObject::connect(canvas, - &PointsCanvas::points_changed, - widget, - [&attr, widget]() - { - Q_EMIT widget->edit_started(); - Q_EMIT widget->value_changed(); - - attr.value_changed.notify(attr.value()); - }); - - // Committed edits (drag release / delete / clear / randomize / csv) - QObject::connect(canvas, - &PointsCanvas::drag_ended, - widget, - [&attr, widget]() - { - Q_EMIT widget->edit_ended(); - - attr.value_changed.notify(attr.value()); - }); - - // Clear - QObject::connect(clear_btn, - &QPushButton::clicked, - canvas, - &PointsCanvas::clear_all); - - // Randomize - QObject::connect(rand_btn, - &QPushButton::clicked, - widget, - [&attr, &value, canvas]() - { - canvas->randomize(value.size()); - attr.value_changed.notify(attr.value()); - }); - - // From CSV - QObject::connect(csv_btn, - &QPushButton::clicked, - widget, - [&attr, canvas, widget]() - { - const QString path = QFileDialog::getOpenFileName( - widget, - QObject::tr("Load points from CSV"), - QDir::homePath(), - QObject::tr("CSV files (*.csv);;All Files (*)"), - nullptr, - QFileDialog::DontUseNativeDialog); - if (!path.isEmpty()) - { - canvas->load_csv(path); - attr.value_changed.notify(attr.value()); - } - }); - } - else // --- ERROR - { - layout->addWidget( - make_error_widget(&attr, "unsupported widget type", widget)); - } - - // connection: attribute changed ==> widget update (dies with the - // widget destruction) - widget->connection_ = attr.value_changed.subscribe( - [widget](std::vector) { widget->sync_widget_from_model(); }); - - return widget; - } -}; - -} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widgets/collapsible_section.hpp b/MetaUI/qt/include/meta_qt/widgets/collapsible_section.hpp index 4e255c7..d01405f 100644 --- a/MetaUI/qt/include/meta_qt/widgets/collapsible_section.hpp +++ b/MetaUI/qt/include/meta_qt/widgets/collapsible_section.hpp @@ -14,15 +14,21 @@ class CollapsibleSection : public QWidget public: explicit CollapsibleSection(const QString &title, QWidget *parent = nullptr); + ~CollapsibleSection() override = default; - void set_expanded(bool new_state); + /// Virtual so a design can animate the transition rather than snapping. + virtual void set_expanded(bool new_state); + + bool is_expanded() const; QVBoxLayout *content_layout; signals: void expanded_state_changed(bool new_state); -private: +protected: + // Protected rather than private so a design can restyle or animate the + // header and body without this class having to anticipate how. QToolButton *toggle_button; QWidget *content; }; diff --git a/MetaUI/qt/include/meta_qt/widgets/points_canvas.hpp b/MetaUI/qt/include/meta_qt/widgets/points_canvas.hpp index 422ca70..783d382 100644 --- a/MetaUI/qt/include/meta_qt/widgets/points_canvas.hpp +++ b/MetaUI/qt/include/meta_qt/widgets/points_canvas.hpp @@ -67,6 +67,10 @@ class PointsCanvas : public QWidget void drag_ended(); protected: + /// Keeps the canvas square: the point domain is square, so a fixed height + /// only matches it at one panel width. + void resizeEvent(QResizeEvent *event) override; + void paintEvent(QPaintEvent *) override; void mousePressEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; diff --git a/MetaUI/qt/src/container_widget/container_widget.cpp b/MetaUI/qt/src/container_widget/container_widget.cpp index ccdea24..f1bf41e 100644 --- a/MetaUI/qt/src/container_widget/container_widget.cpp +++ b/MetaUI/qt/src/container_widget/container_widget.cpp @@ -11,6 +11,7 @@ #include "meta_qt/container_widget.hpp" #include "meta_qt/meta_widget.hpp" +#include "meta_qt/ui/design_registry.hpp" #include "meta_qt/widget_renderer.hpp" #include "meta_qt/widgets/collapsible_section.hpp" #include "meta_qt/widgets/preset_combo_box.hpp" @@ -63,9 +64,30 @@ void insert_attribute(CategoryNode &root, node->attributes.push_back(attr); } -void render_flat(CategoryNode &node, - QVBoxLayout *layout, - std::vector &collected_widgets) +namespace +{ + +/// The stock section unless the caller supplied a design-aware factory. +CollapsibleSection *build_section(const SectionFactory §ion_factory, + const QString &title) +{ + return section_factory ? section_factory(title) + : new CollapsibleSection(title); +} + +/// The stock renderer unless the caller supplied a design-aware one. +MetaWidget *build_row(const AttributeRowRenderer &row_renderer, + AbstractAttribute *p_attr) +{ + return row_renderer ? row_renderer(p_attr) : qt::render(p_attr); +} + +} // namespace + +void render_flat(CategoryNode &node, + QVBoxLayout *layout, + std::vector &collected_widgets, + const AttributeRowRenderer &row_renderer) { Logger::log()->trace("container_widget::render_flat"); @@ -74,7 +96,7 @@ void render_flat(CategoryNode &node, Logger::log()->trace("container_widget::render_flat: '{}'", p_attr ? p_attr->name() : std::string("null")); - MetaWidget *w = qt::render(p_attr); + MetaWidget *w = build_row(row_renderer, p_attr); if (w) { @@ -91,7 +113,10 @@ void render_flat(CategoryNode &node, } for (const auto &name : node.children_order) - render_flat(*node.children.at(name), layout, collected_widgets); + render_flat(*node.children.at(name), + layout, + collected_widgets, + row_renderer); } void render_category(AttributeContainer &container, @@ -99,7 +124,9 @@ void render_category(AttributeContainer &container, QVBoxLayout *parent_layout, std::vector &collected_widgets, std::vector> - &collected_sections) + &collected_sections, + const AttributeRowRenderer &row_renderer, + const SectionFactory §ion_factory) { Logger::log()->trace("container_widget::render_category: '{}'", node.name); @@ -109,7 +136,7 @@ void render_category(AttributeContainer &container, { const std::string title = node.name; - auto *section = new CollapsibleSection(title.c_str()); + auto *section = build_section(section_factory, title.c_str()); parent_layout->addWidget(section); Logger::log()->trace("container_widget::render_category: section '{}'", @@ -144,7 +171,7 @@ void render_category(AttributeContainer &container, Logger::log()->trace("container_widget::render_category: '{}'", p_attr ? p_attr->name() : std::string("null")); - MetaWidget *w = qt::render(p_attr); + MetaWidget *w = build_row(row_renderer, p_attr); if (w) // avoid 'None' type widgets { @@ -165,7 +192,9 @@ void render_category(AttributeContainer &container, *node.children.at(name), current_layout, collected_widgets, - collected_sections); + collected_sections, + row_renderer, + section_factory); } void render_category_merged( @@ -175,7 +204,9 @@ void render_category_merged( std::vector &collected_widgets, std::vector> &collected_sections, - const std::optional &collapse_regex) + const std::optional &collapse_regex, + const AttributeRowRenderer &row_renderer, + const SectionFactory §ion_factory) { Logger::log()->trace("container_widget::render_category_merged"); @@ -203,7 +234,7 @@ void render_category_merged( if (!title.empty()) { - auto *section = new CollapsibleSection(title.c_str()); + auto *section = build_section(section_factory, title.c_str()); const bool autocollapse = collapse_regex && std::regex_search(title, *collapse_regex); @@ -241,7 +272,7 @@ void render_category_merged( Logger::log()->trace("container_widget::render_category_merged: '{}'", p_attr ? p_attr->name() : std::string("null")); - MetaWidget *w = qt::render(p_attr); + MetaWidget *w = build_row(row_renderer, p_attr); if (w) // 'None' widget is possible { @@ -265,7 +296,9 @@ void render_category_merged( layout, collected_widgets, collected_sections, - collapse_regex); + collapse_regex, + row_renderer, + section_factory); } MetaWidget *render(AttributeContainer &container, @@ -327,6 +360,22 @@ MetaWidget *render(AttributeContainer &container, // --- Attribute widgets + AttributeRowRenderer effective_row_renderer = options.row_renderer; + if (!effective_row_renderer) + { + effective_row_renderer = [design = options.design, + ctx = options.row_context, + parent](AbstractAttribute *p_attr) -> MetaWidget * + { return render_row(p_attr, design, ctx, parent); }; + } + + SectionFactory effective_section_factory = options.section_factory; + if (!effective_section_factory) + { + effective_section_factory = DesignRegistry::instance().section_factory( + options.design); + } + std::vector collected_widgets; std::vector> collected_sections; @@ -338,7 +387,9 @@ MetaWidget *render(AttributeContainer &container, root, layout, collected_widgets, - collected_sections); + collected_sections, + effective_row_renderer, + effective_section_factory); break; case CategoryPolicy::CP_MERGED: @@ -348,21 +399,25 @@ MetaWidget *render(AttributeContainer &container, layout, collected_widgets, collected_sections, - options.collapse_regex); + options.collapse_regex, + effective_row_renderer, + effective_section_factory); break; case CategoryPolicy::CP_SMART: Logger::log()->trace("container_widget::render: smart mode"); if (has_no_categorys) - render_flat(root, layout, collected_widgets); + render_flat(root, layout, collected_widgets, effective_row_renderer); else render_category_merged(container, root, layout, collected_widgets, collected_sections, - options.collapse_regex); + options.collapse_regex, + effective_row_renderer, + effective_section_factory); break; #pragma GCC diagnostic push @@ -370,7 +425,9 @@ MetaWidget *render(AttributeContainer &container, case CategoryPolicy::CP_FLAT: Logger::log()->trace("container_widget::render: flat mode"); - default: render_flat(root, layout, collected_widgets); break; + default: + render_flat(root, layout, collected_widgets, effective_row_renderer); + break; #pragma GCC diagnostic pop } diff --git a/MetaUI/qt/src/designs/industrial/check_row.cpp b/MetaUI/qt/src/designs/industrial/check_row.cpp new file mode 100644 index 0000000..d32eb03 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/check_row.cpp @@ -0,0 +1,174 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/designs/industrial/check_row.hpp" + +#include + +#include +#include +#include +#include + +namespace meta::qt::industrial +{ + +CheckRow::CheckRow(Attribute &attr, + const RowContext &ctx, + QWidget *parent) + : Control(ctx, parent) +{ + key_ = attr.name(); + label_ = meta::common::label(attr); + value_ = attr.value(); + knob_ = value_ ? 1.0 : 0.0; + + setFixedHeight(theme().metrics.check_row_height); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + + glide_ = new Glide(theme().metrics.switch_ms, this); + connect(glide_, + &Glide::tick, + this, + [this](qreal t) + { + knob_ = t; + update(); + }); + glide_->jump(knob_); +} + +void CheckRow::set(const bool &value) +{ + if (value_ == value) return; + value_ = value; + glide_->to(value_ ? 1.0 : 0.0); + update(); +} + +QSize CheckRow::sizeHint() const +{ + const Metrics &m = theme().metrics; + return QSize(m.label_min_width + m.gap + m.switch_width, m.check_row_height); +} + +QRect CheckRow::switch_rect() const +{ + const Metrics &m = theme().metrics; + return QRect(width() - m.switch_width, + (height() - m.switch_height) / 2, + m.switch_width, + m.switch_height); +} + +void CheckRow::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + + const Theme &t = theme(); + const Metrics &m = t.metrics; + const bool locked = is_locked(); + + // --- label + QFont label_font = ui_font(12, false, 1.0); + label_font.setCapitalization(QFont::AllUppercase); + painter.setFont(label_font); + painter.setPen(t.state_ink(is_modified(), locked)); + const int label_w = width() - m.switch_width - m.gap; + painter.drawText( + QRect(0, 0, label_w, height()), + Qt::AlignLeft | Qt::AlignVCenter, + elide_label(QString::fromStdString(label_), label_font, label_w)); + + painter.setOpacity(locked ? t.locked_thumb_alpha : 1.0); + + // --- track. On uses the accent, consistent with the rail: fills are accent, + // never a state colour. + const QRect track = switch_rect(); + QColor track_color = t.switch_track_off; + if (knob_ > 0.0) + { + QColor on = t.switch_track_on(); + // blend across the travel so the track follows the knob rather than + // snapping at the midpoint + track_color = QColor::fromRgbF( + t.switch_track_off.redF() * (1.0 - knob_) + on.redF() * knob_, + t.switch_track_off.greenF() * (1.0 - knob_) + on.greenF() * knob_, + t.switch_track_off.blueF() * (1.0 - knob_) + on.blueF() * knob_); + } + + painter.setPen(QPen(t.hairline, 1)); + painter.setBrush(track_color); + painter.drawRoundedRect(QRectF(track).adjusted(0.5, 0.5, -0.5, -0.5), + m.radius, + m.radius); + + // --- knob + const int travel = m.switch_width - m.knob_size - 2 * m.knob_inset; + const QRect knob(track.x() + m.knob_inset + int(std::round(knob_ * travel)), + track.y() + m.knob_inset, + m.knob_size, + m.switch_height - 2 * m.knob_inset); + + QLinearGradient metal(knob.topLeft(), knob.bottomLeft()); + metal.setColorAt(0.0, value_ ? t.knob_on_top : t.knob_off_top); + metal.setColorAt(1.0, value_ ? t.knob_on_bottom : t.knob_off_bottom); + + painter.setPen(QPen(t.thumb_border, 1)); + painter.setBrush(metal); + painter.drawRoundedRect(QRectF(knob).adjusted(0.5, 0.5, -0.5, -0.5), + m.radius, + m.radius); + + painter.setOpacity(1.0); +} + +void CheckRow::mousePressEvent(QMouseEvent *event) +{ + if (is_locked() || event->button() != Qt::LeftButton) + { + event->ignore(); + return; + } + + setFocus(Qt::MouseFocusReason); + pressed_ = true; +} + +void CheckRow::mouseReleaseEvent(QMouseEvent *event) +{ + if (!pressed_) return; + pressed_ = false; + + // The whole row is the hit target, not just the switch. + if (rect().contains(event->pos())) toggle(); +} + +void CheckRow::keyPressEvent(QKeyEvent *event) +{ + if (!is_locked() && + (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return)) + { + toggle(); + event->accept(); + return; + } + + Control::keyPressEvent(event); +} + +void CheckRow::toggle() +{ + value_ = !value_; + glide_->to(value_ ? 1.0 : 0.0); + update(); + + // A discrete control has no drag, so the whole edit is one instant: the + // binder still sees a well-formed started/changed/ended sequence. + begin_edit(); + notify_value_changed(); + end_edit(); +} + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/src/designs/industrial/combo.cpp b/MetaUI/qt/src/designs/industrial/combo.cpp new file mode 100644 index 0000000..b30f8f3 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/combo.cpp @@ -0,0 +1,517 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/designs/industrial/combo.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace meta::qt::industrial +{ + +namespace +{ + +constexpr int kRowHeight = 26; +constexpr int kPopupPadding = 4; +constexpr int kMaxVisibleRows = 12; + +/// Timestamp of the last popup close, for the close-then-reopen race. +qint64 g_last_close_ms = 0; + +} // namespace + +// --- ComboPopup + +ComboPopup::ComboPopup(const Theme &theme, + const QStringList &items, + int current, + QWidget *parent) + : QWidget(parent, Qt::Popup), + theme_(&theme), + items_(items), + current_(current), + hovered_(current) +{ + // Must be set before the native window is created, which happens on the first + // show(). Setting it later leaves the unrevealed part of the surface painting + // opaque black instead of nothing. + // + // WA_NoSystemBackground is deliberately *not* set alongside it: together they + // leave the surface undefined here rather than clear. + setAttribute(Qt::WA_TranslucentBackground); + + setAttribute(Qt::WA_DeleteOnClose); + setMouseTracking(true); + setFocusPolicy(Qt::StrongFocus); +} + +int ComboPopup::row_height() const { return kRowHeight; } + +bool ComboPopup::should_swallow_reopen() +{ + return QDateTime::currentMSecsSinceEpoch() - g_last_close_ms < 200; +} + +void ComboPopup::popup_for(const QRect &field_global) +{ + const int visible = std::min(items_.size(), kMaxVisibleRows); + full_height_ = visible * row_height() + 2 * kPopupPadding; + + const int width = std::max(field_global.width(), 120); + + const QRect screen = QApplication::primaryScreen()->availableGeometry(); + const bool fits_below = field_global.bottom() + full_height_ <= + screen.bottom(); + + const int left = field_global.left(); + flipped_ = !fits_below; + + const int y = fits_below ? field_global.bottom() + 2 + : field_global.top() - 2 - full_height_; + + // The window is created at its final size and never resized. Animating a + // top-level window is geometry does not work here: the platform enforces a + // minimum window size and coalesces rapid resizes, so the popup simply snaps + // to full size, and resizing a native window every frame is expensive anyway. + // Reveal the card inside a fixed, translucent window instead. + setGeometry(left, y, width, full_height_); + show(); + setFocus(Qt::PopupFocusReason); + + open_animation_ = new QVariantAnimation(this); + open_animation_->setDuration(theme_->metrics.section_ms); + open_animation_->setEasingCurve(QEasingCurve::OutCubic); + open_animation_->setStartValue(0); + open_animation_->setEndValue(full_height_); + + connect(open_animation_, + &QVariantAnimation::valueChanged, + this, + [this](const QVariant &v) + { + revealed_ = v.toInt(); + update(); + }); + + open_animation_->start(); +} + +QRect ComboPopup::card_rect() const +{ + const int h = revealed_ > 0 ? revealed_ : full_height_; + + // Opening downward, the card grows from its top edge, which sits against the + // field. Flipped, it grows upward from its bottom edge, which is the edge + // touching the field -- otherwise it looks like it falls from the ceiling. + return flipped_ ? QRect(0, full_height_ - h, width(), h) + : QRect(0, 0, width(), h); +} + +int ComboPopup::index_at(const QPoint &pos) const +{ + if (!rect().contains(pos)) return -1; + + const int index = (pos.y() - kPopupPadding) / row_height(); + return index >= 0 && index < items_.size() ? index : -1; +} + +void ComboPopup::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + + const Theme &t = *theme_; + const QRect card = card_rect(); + + // Everything is clipped to the revealed card, so the rows stay put and are + // uncovered rather than sliding. Laying them out against the animating height + // would read as the list scrolling instead of opening. + painter.setClipRect(card); + + // One surface, painted once: frame and rows come from the same pass so they + // cannot disagree about colour part-way through opening. + painter.setPen(QPen(t.hairline, 1)); + painter.setBrush(t.bar); + painter.drawRoundedRect(QRectF(card).adjusted(0.5, 0.5, -0.5, -0.5), + t.metrics.radius, + t.metrics.radius); + + painter.setFont(ui_font(12)); + + for (int i = 0; i < items_.size(); ++i) + { + const QRect row(1, + kPopupPadding + i * row_height(), + width() - 2, + row_height()); + if (!row.intersects(card)) continue; + + if (i == hovered_) + { + painter.setPen(Qt::NoPen); + painter.setBrush(t.section_header); + painter.drawRect(row); + } + + painter.setPen(i == current_ ? t.accent : t.ink_primary); + painter.drawText(row.adjusted(10, 0, -10, 0), + Qt::AlignLeft | Qt::AlignVCenter, + items_.at(i)); + } +} + +void ComboPopup::mouseMoveEvent(QMouseEvent *event) +{ + const int index = index_at(event->pos()); + if (index != hovered_) + { + hovered_ = index; + update(); + } +} + +void ComboPopup::mousePressEvent(QMouseEvent *event) +{ + // Overriding this at all suppresses Qt's built-in "press outside dismisses", + // so an outside press has to be handled here. + if (!rect().contains(event->pos())) + { + close(); + return; + } + + QWidget::mousePressEvent(event); +} + +void ComboPopup::mouseReleaseEvent(QMouseEvent *event) +{ + const int index = index_at(event->pos()); + if (index >= 0) + { + Q_EMIT selected(index); + close(); + return; + } + + if (!rect().contains(event->pos())) close(); +} + +void ComboPopup::keyPressEvent(QKeyEvent *event) +{ + switch (event->key()) + { + case Qt::Key_Down: + hovered_ = std::min(hovered_ + 1, items_.size() - 1); + update(); + return; + case Qt::Key_Up: + hovered_ = std::max(hovered_ - 1, 0); + update(); + return; + case Qt::Key_Return: + case Qt::Key_Enter: + if (hovered_ >= 0) Q_EMIT selected(hovered_); + close(); + return; + case Qt::Key_Escape: close(); return; + default: break; + } + + QWidget::keyPressEvent(event); +} + +void ComboPopup::hideEvent(QHideEvent *event) +{ + // hideEvent rather than destroyed(): WA_DeleteOnClose defers deletion by an + // event-loop pass, by which point the dismissing click has already been + // processed and reopened the popup. + g_last_close_ms = QDateTime::currentMSecsSinceEpoch(); + QWidget::hideEvent(event); +} + +// --- shared field painting + +void paint_combo_field(QWidget &widget, + const Theme &theme, + const QString &label, + const QString &value, + bool open, + bool modified, + bool locked) +{ + QPainter painter(&widget); + painter.setRenderHint(QPainter::Antialiasing, true); + + const Metrics &m = theme.metrics; + const int height = widget.height(); + + const int label_width = int( + std::clamp(widget.width() * m.label_width_ratio, + m.label_min_width, + m.label_max_width)); + + QFont label_font = ui_font(12, false, 1.0); + label_font.setCapitalization(QFont::AllUppercase); + painter.setFont(label_font); + painter.setPen(theme.state_ink(modified, locked)); + painter.drawText(QRect(0, 0, label_width, height), + Qt::AlignLeft | Qt::AlignVCenter, + label); + + const QRect field(label_width + m.gap, + (height - m.value_field_height) / 2, + widget.width() - label_width - m.gap, + m.value_field_height); + + painter.setOpacity(locked ? theme.locked_thumb_alpha : 1.0); + + // An open combo takes the accent border, matching an editing value field. + painter.setPen(QPen(open ? theme.accent : theme.field_border, 1)); + painter.setBrush(theme.field); + painter.drawRoundedRect(QRectF(field).adjusted(0.5, 0.5, -0.5, -0.5), + m.radius, + m.radius); + + painter.setFont(ui_font(12)); + painter.setPen(theme.ink_primary); + painter.drawText(field.adjusted(8, 0, -22, 0), + Qt::AlignLeft | Qt::AlignVCenter, + painter.fontMetrics().elidedText(value, + Qt::ElideRight, + field.width() - 30)); + + // chevron + const QPoint centre(field.right() - 12, field.center().y()); + QPainterPath chevron; + chevron.moveTo(centre.x() - 4, centre.y() - 2); + chevron.lineTo(centre.x(), centre.y() + 2); + chevron.lineTo(centre.x() + 4, centre.y() - 2); + + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(theme.ink_icon, 1.5)); + painter.drawPath(chevron); + + painter.setOpacity(1.0); +} + +// --- EnumCombo + +EnumCombo::EnumCombo(Attribute &attr, + const RowContext &ctx, + QWidget *parent) + : Control(ctx, parent) +{ + label_ = meta::common::label(attr); + items_ = meta::common::enum_items(attr); + value_ = attr.value(); + + setFixedHeight(theme().metrics.row_height); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); +} + +bool EnumCombo::can_render(const Attribute &attr) +{ + return !meta::common::enum_items(attr).empty(); +} + +void EnumCombo::set(const int &value) +{ + value_ = value; + update(); +} + +QSize EnumCombo::sizeHint() const +{ + return QSize(theme().metrics.label_min_width + 160, + theme().metrics.row_height); +} + +void EnumCombo::paintEvent(QPaintEvent *) +{ + QString text; + for (const auto &[v, name] : items_) + if (v == value_) text = QString::fromStdString(name); + + paint_combo_field(*this, + theme(), + QString::fromStdString(label_), + text, + open_, + is_modified(), + is_locked()); +} + +void EnumCombo::mouseReleaseEvent(QMouseEvent *event) +{ + // Open on release. Opening on press means the matching release lands outside + // the new popup and dismisses it immediately. + if (is_locked() || event->button() != Qt::LeftButton) return; + if (ComboPopup::should_swallow_reopen()) return; + + open_popup(); +} + +void EnumCombo::open_popup() +{ + QStringList names; + int current = -1; + for (int i = 0; i < int(items_.size()); ++i) + { + names << QString::fromStdString(items_.at(i).second); + if (items_.at(i).first == value_) current = i; + } + + auto *popup = new ComboPopup(theme(), names, current, this); + + connect(popup, + &ComboPopup::selected, + this, + [this](int index) + { + if (index < 0 || index >= int(items_.size())) return; + + value_ = items_.at(index).first; + update(); + + begin_edit(); + notify_value_changed(); + end_edit(); + }); + + connect(popup, + &QObject::destroyed, + this, + [this]() + { + open_ = false; + update(); + }); + + open_ = true; + update(); + + const Metrics &m = theme().metrics; + const int label_width = int(std::clamp(width() * m.label_width_ratio, + m.label_min_width, + m.label_max_width)); + const QRect field(label_width + m.gap, + (height() - m.value_field_height) / 2, + width() - label_width - m.gap, + m.value_field_height); + + popup->popup_for(QRect(mapToGlobal(field.topLeft()), field.size())); +} + +// --- StringCombo + +StringCombo::StringCombo(Attribute &attr, + const RowContext &ctx, + QWidget *parent) + : Control(ctx, parent) +{ + label_ = meta::common::label(attr); + items_ = meta::common::allowed_values(attr); + value_ = attr.value(); + + setFixedHeight(theme().metrics.row_height); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); +} + +bool StringCombo::can_render(const Attribute &attr) +{ + return !meta::common::allowed_values(attr).empty(); +} + +void StringCombo::set(const std::string &value) +{ + value_ = value; + update(); +} + +QSize StringCombo::sizeHint() const +{ + return QSize(theme().metrics.label_min_width + 160, + theme().metrics.row_height); +} + +void StringCombo::paintEvent(QPaintEvent *) +{ + paint_combo_field(*this, + theme(), + QString::fromStdString(label_), + QString::fromStdString(value_), + open_, + is_modified(), + is_locked()); +} + +void StringCombo::mouseReleaseEvent(QMouseEvent *event) +{ + if (is_locked() || event->button() != Qt::LeftButton) return; + if (ComboPopup::should_swallow_reopen()) return; + + open_popup(); +} + +void StringCombo::open_popup() +{ + QStringList names; + int current = -1; + for (int i = 0; i < int(items_.size()); ++i) + { + names << QString::fromStdString(items_.at(i)); + if (items_.at(i) == value_) current = i; + } + + auto *popup = new ComboPopup(theme(), names, current, this); + + connect(popup, + &ComboPopup::selected, + this, + [this](int index) + { + if (index < 0 || index >= int(items_.size())) return; + + value_ = items_.at(index); + update(); + + begin_edit(); + notify_value_changed(); + end_edit(); + }); + + connect(popup, + &QObject::destroyed, + this, + [this]() + { + open_ = false; + update(); + }); + + open_ = true; + update(); + + const Metrics &m = theme().metrics; + const int label_width = int(std::clamp(width() * m.label_width_ratio, + m.label_min_width, + m.label_max_width)); + const QRect field(label_width + m.gap, + (height() - m.value_field_height) / 2, + width() - label_width - m.gap, + m.value_field_height); + + popup->popup_for(QRect(mapToGlobal(field.topLeft()), field.size())); +} + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/src/designs/industrial/industrial.cpp b/MetaUI/qt/src/designs/industrial/industrial.cpp new file mode 100644 index 0000000..188d7be --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/industrial.cpp @@ -0,0 +1,70 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/designs/industrial/industrial.hpp" + +#include "meta_qt/designs/industrial/check_row.hpp" +#include "meta_qt/designs/industrial/combo.hpp" +#include "meta_qt/designs/industrial/int_slider.hpp" +#include "meta_qt/designs/industrial/param_slider.hpp" +#include "meta_qt/designs/industrial/section.hpp" +#include "meta_qt/designs/stock/stock.hpp" +#include "meta_qt/ui/design_registry.hpp" +#include "meta_qt/ui/theme.hpp" + +namespace meta::qt::industrial +{ + +void register_design() +{ + static bool registered = false; + if (registered) return; + registered = true; + + DesignRegistry ®istry = DesignRegistry::instance(); + + // --- Theme & section chrome + registry.set_theme(kDesignName, ThemeRegistry::kPaletteTheme); + registry.register_section_factory( + kDesignName, + [](const QString &title) + { + // Read the design's theme rather than naming one. set_theme() exists so + // a host can pick the colourway, and pinning the palette theme here + // ignored it: the rows followed the setting while the section cards + // stayed on the app palette, so the two drew different greys. + // + // Resolved per section rather than captured, because the theme can be + // set after the design registers. + return new Section(title, DesignRegistry::instance().theme(kDesignName)); + }); + + // --- float: 58% of the rows in a Hesiod node panel + registry.register_control(kDesignName, "SliderFloat"); + + // --- bool: 14%. Both presets map to the same control for now; BinaryButtons + // wants its own A/B chip pair, which is a separate design entry rather than a + // branch inside CheckRow. + registry.register_control(kDesignName, "Toggle"); + registry.register_control(kDesignName, "Checkbox"); + + // --- int: 13%, counting Seed. Shares its chrome with ParamSlider via + // slider_chrome so the two rows cannot drift apart. + registry.register_control(kDesignName, "SliderInt"); + + // --- dropdowns. Both use a popup of our own rather than QComboBox: the stock + // popup composites a separately styled frame and item view, which is why it + // shows one surface mid-open and another once settled. + registry.register_control(kDesignName, "EnumComboBox"); + registry.register_control(kDesignName, "ComboBox"); + registry.register_control(kDesignName, + "ButtonGrid"); + + // Anything not covered above resolves through stock, so a design still under + // construction yields a complete panel rather than a handful of rows. Drop + // this line and the unported widget types simply render nothing. + stock::register_design(); + registry.set_fallback(kDesignName, stock::kDesignName); +} + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/src/designs/industrial/int_slider.cpp b/MetaUI/qt/src/designs/industrial/int_slider.cpp new file mode 100644 index 0000000..91251d4 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/int_slider.cpp @@ -0,0 +1,318 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/designs/industrial/int_slider.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +namespace meta::qt::industrial +{ + +IntSlider::IntSlider(Attribute &attr, + const RowContext &ctx, + QWidget *parent) + : Control(ctx, parent) +{ + key_ = attr.name(); + label_ = meta::common::label(attr); + category_ = meta::common::category(attr); + min_ = meta::common::min(attr); + max_ = meta::common::max(attr); + + value_ = std::clamp(attr.value(), min_, max_); + norm_ = to_norm(value_); + + setFixedHeight(theme().metrics.row_height); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + + glide_ = new Glide(theme().metrics.glide_ms, this); + + // The glide drives the painted position only. value_ is set up front by + // apply_value(), so neither the readout nor the model ever shows an + // intermediate fractional value that an int attribute cannot hold. + connect(glide_, + &Glide::tick, + this, + [this](qreal t) + { + norm_ = t; + update(); + }); + + connect(glide_, + &Glide::finished, + this, + [this](qreal t) + { + norm_ = t; + update(); + end_edit(); + }); + glide_->jump(norm_); + + field_ = new QLineEdit(this); + field_->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + field_->setFrame(false); + field_->setFont(mono_font(13)); + field_->installEventFilter(this); + refresh_field(); + restyle_field(); + + connect(field_, + &QLineEdit::editingFinished, + this, + [this]() + { + bool ok = false; + const int typed = field_->text().toInt(&ok); + if (!ok) + { + refresh_field(); // reject silently, restore the real value + return; + } + + begin_edit(); + apply_value(std::clamp(typed, min_, max_), true); + }); + + connect(field_, + &QLineEdit::textEdited, + this, + [this]() { restyle_field(true); }); +} + +bool IntSlider::can_render(const Attribute &attr) +{ + const auto &metadata = attr.metadata(); + if (!metadata.find(meta::keys::constraints::min) || + !metadata.find(meta::keys::constraints::max)) + return false; + + return meta::common::max(attr) > meta::common::min(attr); +} + +void IntSlider::set(const int &value) +{ + value_ = std::clamp(value, min_, max_); + glide_->jump(to_norm(value_)); // a model sync seats immediately + norm_ = to_norm(value_); + refresh_field(); + update(); +} + +QSize IntSlider::sizeHint() const +{ + return QSize(theme().metrics.label_min_width + 200, + theme().metrics.row_height); +} + +qreal IntSlider::to_norm(int value) const +{ + if (max_ <= min_) return 0.0; + return std::clamp(qreal(value - min_) / qreal(max_ - min_), 0.0, 1.0); +} + +int IntSlider::from_norm(qreal t) const +{ + // Round rather than truncate, or the top step is unreachable and dragging + // right never quite arrives at max. + return int(std::lround(min_ + std::clamp(t, 0.0, 1.0) * qreal(max_ - min_))); +} + +void IntSlider::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + + const SliderGeometry geometry = SliderGeometry::compute(theme(), + width(), + height(), + norm_); + + SliderVisual visual; + visual.category = category_; + visual.modified = is_modified(); + visual.locked = is_locked(); + + QFont label_font = ui_font(12, false, 1.0); + label_font.setCapitalization(QFont::AllUppercase); + visual.label = elide_label(QString::fromStdString(label_), + label_font, + geometry.label.width()); + + paint_slider_row(painter, theme(), geometry, visual, height()); +} + +void IntSlider::resizeEvent(QResizeEvent *event) +{ + if (event->oldSize().width() == event->size().width()) + { + QWidget::resizeEvent(event); + return; + } + + field_->setGeometry( + SliderGeometry::compute(theme(), width(), height(), norm_).field); + + QWidget::resizeEvent(event); +} + +void IntSlider::mousePressEvent(QMouseEvent *event) +{ + if (is_locked() || event->button() != Qt::LeftButton) + { + event->ignore(); + return; + } + + const QRect rail = SliderGeometry::compute(theme(), width(), height(), norm_) + .rail; + if (!rail.adjusted(-4, -10, 4, 10).contains(event->pos())) + { + event->ignore(); + return; + } + + setFocus(Qt::MouseFocusReason); + dragging_ = true; + begin_edit(); + set_from_position(event->pos().x()); +} + +void IntSlider::mouseMoveEvent(QMouseEvent *event) +{ + if (!dragging_) return; + set_from_position(event->pos().x()); +} + +void IntSlider::mouseReleaseEvent(QMouseEvent *event) +{ + if (!dragging_) return; + + dragging_ = false; + set_from_position(event->pos().x()); + end_edit(); +} + +void IntSlider::mouseDoubleClickEvent(QMouseEvent *event) +{ + if (is_locked()) return; + + const auto &provider = context().default_value; + if (!provider) return; + + const std::any def = provider(key_); + if (!def.has_value()) return; + + try + { + dragging_ = false; + begin_edit(); + apply_value(std::clamp(std::any_cast(def), min_, max_), true); + } + catch (const std::bad_any_cast &) + { + // A default of the wrong type is a host bug, not a reason to misbehave. + } + + event->accept(); +} + +void IntSlider::handle_wheel(QWheelEvent *event) +{ + const int steps = event->angleDelta().y() / 120; + if (steps == 0) + { + event->ignore(); + return; + } + + // One notch is one unit, which is what an integer control should do + // regardless of how wide its range happens to be. + begin_edit(); + apply_value(std::clamp(value_ + steps, min_, max_), true); + event->accept(); +} + +void IntSlider::on_state_changed() +{ + restyle_field(field_ && field_->hasFocus()); + update(); +} + +bool IntSlider::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == field_ && + (event->type() == QEvent::FocusIn || event->type() == QEvent::FocusOut)) + { + const bool editing = event->type() == QEvent::FocusIn; + if (!editing) refresh_field(); + restyle_field(editing); + } + + return Control::eventFilter(watched, event); +} + +void IntSlider::set_from_position(int x) +{ + const Metrics &m = theme().metrics; + const SliderGeometry g = SliderGeometry::compute(theme(), + width(), + height(), + norm_); + const int travel = std::max(1, g.rail.width() - m.thumb_width); + + const qreal t = std::clamp(qreal(x - g.rail.x() - m.thumb_width / 2) / travel, + 0.0, + 1.0); + + // Quantise to the integer the rail actually represents, so the thumb sits on + // whole values during a drag rather than between them. + apply_value(from_norm(t), false); +} + +void IntSlider::apply_value(int value, bool glide) +{ + const bool changed = value != value_; + value_ = value; + + if (glide) + { + glide_->to(to_norm(value_)); + } + else + { + glide_->jump(to_norm(value_)); // a drag tracks the cursor, no glide + norm_ = to_norm(value_); + } + + refresh_field(); + update(); + + if (changed) notify_value_changed(); +} + +void IntSlider::refresh_field() +{ + if (!field_ || field_->hasFocus()) return; // never overwrite mid-typing + + const QSignalBlocker blocker(field_); + field_->setText(QString::number(value_)); +} + +void IntSlider::restyle_field(bool editing) +{ + if (!field_) return; + + field_->setReadOnly(is_locked()); + field_->setStyleSheet( + field_stylesheet(theme(), editing, is_modified(), is_locked())); +} + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/src/designs/industrial/panel_chrome.cpp b/MetaUI/qt/src/designs/industrial/panel_chrome.cpp new file mode 100644 index 0000000..a71153f --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/panel_chrome.cpp @@ -0,0 +1,50 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/designs/industrial/panel_chrome.hpp" + +namespace meta::qt::industrial +{ + +QString scrollbar_stylesheet(const Theme &theme) +{ + return QString("QScrollArea { background: transparent; border: none; }" + "QScrollBar:vertical {" + " background: transparent;" + " width: 10px;" + " margin: 0px;" + "}" + "QScrollBar::handle:vertical {" + " background: %1;" + " min-height: 30px;" + " border-radius: 2px;" + " margin: 2px 3px 2px 3px;" + "}" + "QScrollBar::handle:vertical:hover { background: %2; }" + // No arrows, and no page-step background: the groove should be + // invisible so only the handle reads as chrome. + "QScrollBar::add-line:vertical," + "QScrollBar::sub-line:vertical { height: 0px; }" + "QScrollBar::add-page:vertical," + "QScrollBar::sub-page:vertical { background: transparent; }") + .arg(theme.field_border.name()) + .arg(theme.field_border_hover.name()); +} + +QString tooltip_stylesheet(const Theme &theme) +{ + return QString("QToolTip {" + " background: %1;" + " color: %2;" + " border: 1px solid %3;" + " border-radius: %4px;" + " padding: 4px 7px;" + " font-size: 11px;" + "}") + .arg(theme.bar.name()) + .arg(theme.ink_primary.name()) + .arg(theme.hairline.name()) + .arg(theme.metrics.radius); +} + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/src/designs/industrial/param_slider.cpp b/MetaUI/qt/src/designs/industrial/param_slider.cpp new file mode 100644 index 0000000..01c80db --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/param_slider.cpp @@ -0,0 +1,374 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/designs/industrial/param_slider.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace meta::qt::industrial +{ + +namespace +{ +constexpr qreal kLogFloor = 1e-6; ///< below this a log mapping is undefined +} + +ParamSlider::ParamSlider(Attribute &attr, + const RowContext &ctx, + QWidget *parent) + : Control(ctx, parent) +{ + key_ = attr.name(); + label_ = meta::common::label(attr); + category_ = meta::common::category(attr); + min_ = meta::common::min(attr); + max_ = meta::common::max(attr); + log_scale_ = meta::common::try_get(attr, + meta::keys::ui::log_scale, + false); + decimals_ = meta::common::try_get_format_decimals(meta::common::format(attr)); + + // A log mapping needs a strictly positive lower bound; fall back to linear + // rather than producing NaNs across the whole rail. + if (log_scale_ && min_ <= kLogFloor) log_scale_ = false; + + value_ = std::clamp(attr.value(), min_, max_); + norm_ = to_norm(value_); + + setFixedHeight(theme().metrics.row_height); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + + glide_ = new Glide(theme().metrics.glide_ms, this); + connect(glide_, + &Glide::tick, + this, + [this](qreal t) + { + norm_ = t; + value_ = from_norm(t); + refresh_field(); + update(); + }); + + // The commit rides the animation's completion. Emitting it when the glide + // starts would publish the value the control still held a frame ago. + connect(glide_, + &Glide::finished, + this, + [this](qreal t) + { + norm_ = t; + value_ = from_norm(t); + refresh_field(); + update(); + notify_value_changed(); + end_edit(); + }); + glide_->jump(norm_); + + field_ = new QLineEdit(this); + field_->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + field_->setFrame(false); + field_->setFont(mono_font(13)); + field_->installEventFilter(this); + refresh_field(); + restyle_field(); + + connect(field_, + &QLineEdit::editingFinished, + this, + [this]() + { + bool ok = false; + const float typed = field_->text().toFloat(&ok); + if (!ok) + { + refresh_field(); // reject silently, restore the real value + return; + } + + begin_edit(); + glide_->to(to_norm(std::clamp(typed, min_, max_))); + }); + + connect(field_, + &QLineEdit::textEdited, + this, + [this]() { restyle_field(true); }); +} + +bool ParamSlider::can_render(const Attribute &attr) +{ + // contains_all_keys() is non-const, so probe with find() instead of taking a + // mutable reference just to ask a question. + const auto &metadata = attr.metadata(); + if (!metadata.find(meta::keys::constraints::min) || + !metadata.find(meta::keys::constraints::max)) + return false; + + return meta::common::max(attr) > meta::common::min(attr); +} + +void ParamSlider::set(const float &value) +{ + const float clamped = std::clamp(value, min_, max_); + + // jump() emits tick(), which derives value_ back out of the normalised + // position -- lossy under a log mapping. Seat the authoritative value after. + glide_->jump(to_norm(clamped)); // a model sync seats immediately, no glide + value_ = clamped; + + refresh_field(); + update(); +} + +QSize ParamSlider::sizeHint() const +{ + return QSize(theme().metrics.label_min_width + 200, + theme().metrics.row_height); +} + +// --- value mapping + +qreal ParamSlider::to_norm(float value) const +{ + if (max_ <= min_) return 0.0; + + if (log_scale_) + { + const qreal lo = std::log(qreal(min_)); + const qreal hi = std::log(qreal(max_)); + const qreal v = std::log(std::max(qreal(value), kLogFloor)); + return std::clamp((v - lo) / (hi - lo), 0.0, 1.0); + } + + return std::clamp(qreal(value - min_) / qreal(max_ - min_), 0.0, 1.0); +} + +float ParamSlider::from_norm(qreal t) const +{ + t = std::clamp(t, 0.0, 1.0); + + if (log_scale_) + { + const qreal lo = std::log(qreal(min_)); + const qreal hi = std::log(qreal(max_)); + return float(std::exp(lo + t * (hi - lo))); + } + + return float(min_ + t * qreal(max_ - min_)); +} + +// --- painting + +void ParamSlider::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + + const SliderGeometry geometry = SliderGeometry::compute(theme(), + width(), + height(), + norm_); + + SliderVisual visual; + visual.category = category_; + visual.modified = is_modified(); + visual.locked = is_locked(); + + QFont label_font = ui_font(12, false, 1.0); + label_font.setCapitalization(QFont::AllUppercase); + visual.label = elide_label(QString::fromStdString(label_), + label_font, + geometry.label.width()); + + paint_slider_row(painter, theme(), geometry, visual, height()); +} + +void ParamSlider::resizeEvent(QResizeEvent *event) +{ + // Nothing here derives height from width, so a pure-height resize would be a + // wasted layout pass. Bail before touching child geometry. + if (event->oldSize().width() == event->size().width()) + { + QWidget::resizeEvent(event); + return; + } + + field_->setGeometry( + SliderGeometry::compute(theme(), width(), height(), norm_).field); + + QWidget::resizeEvent(event); +} + +// --- interaction + +void ParamSlider::mousePressEvent(QMouseEvent *event) +{ + if (is_locked() || event->button() != Qt::LeftButton) + { + event->ignore(); + return; + } + + const QRect rail = SliderGeometry::compute(theme(), width(), height(), norm_) + .rail; + if (!rail.adjusted(-4, -10, 4, 10).contains(event->pos())) + { + event->ignore(); + return; + } + + setFocus(Qt::MouseFocusReason); + dragging_ = true; + begin_edit(); + set_from_position(event->pos().x()); +} + +void ParamSlider::mouseMoveEvent(QMouseEvent *event) +{ + if (!dragging_) return; + set_from_position(event->pos().x()); +} + +void ParamSlider::mouseReleaseEvent(QMouseEvent *event) +{ + if (!dragging_) return; + + dragging_ = false; + set_from_position(event->pos().x()); + end_edit(); +} + +void ParamSlider::mouseDoubleClickEvent(QMouseEvent *event) +{ + if (is_locked()) return; + + const auto &provider = context().default_value; + if (!provider) return; + + const std::any def = provider(key_); + if (!def.has_value()) return; + + try + { + const float target = std::clamp(std::any_cast(def), min_, max_); + dragging_ = false; + begin_edit(); + glide_->to(to_norm(target)); // the reset glides like everything else + } + catch (const std::bad_any_cast &) + { + // A default of the wrong type is a host bug, not a reason to misbehave. + } + + event->accept(); +} + +void ParamSlider::handle_wheel(QWheelEvent *event) +{ + const int steps = event->angleDelta().y() / 120; + if (steps == 0) + { + event->ignore(); + return; + } + + // One notch moves 1% of the rail, which stays sane under a log mapping. + begin_edit(); + glide_->to(std::clamp(norm_ + steps * 0.01, 0.0, 1.0)); + event->accept(); +} + +void ParamSlider::on_state_changed() +{ + restyle_field(field_ && field_->hasFocus()); + update(); +} + +// --- helpers + +bool ParamSlider::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == field_ && + (event->type() == QEvent::FocusIn || event->type() == QEvent::FocusOut)) + { + // hasFocus() is not yet settled while the event is being delivered, so the + // event type is the authority on which way the transition goes. + const bool editing = event->type() == QEvent::FocusIn; + if (!editing) refresh_field(); + restyle_field(editing); + } + + return Control::eventFilter(watched, event); +} + +void ParamSlider::set_from_position(int x) +{ + const Metrics &m = theme().metrics; + const SliderGeometry g = SliderGeometry::compute(theme(), + width(), + height(), + norm_); + const int travel = std::max(1, g.rail.width() - m.thumb_width); + + apply_norm( + std::clamp(qreal(x - g.rail.x() - m.thumb_width / 2) / travel, 0.0, 1.0)); +} + +void ParamSlider::apply_norm(qreal t) +{ + // A drag tracks the cursor directly; gliding here would lag the pointer. + glide_->jump(t); + norm_ = t; + value_ = from_norm(t); + refresh_field(); + update(); + notify_value_changed(); +} + +QString ParamSlider::format_value(float value) const +{ + return QString::number(value, 'f', decimals_); +} + +void ParamSlider::refresh_field() +{ + if (!field_ || field_->hasFocus()) return; // never overwrite mid-typing + + const QSignalBlocker blocker(field_); + field_->setText(format_value(value_)); +} + +void ParamSlider::restyle_field(bool editing) +{ + if (!field_) return; + + const Theme &t = theme(); + + const QColor bg = editing ? t.field_editing : t.field; + const QColor border = editing ? t.accent : t.field_border; + + field_->setReadOnly(is_locked()); + field_->setStyleSheet( + QString("QLineEdit {" + " background: %1;" + " border: 1px solid %2;" + " border-radius: %3px;" + " color: %4;" + " padding-right: 4px;" + "}") + .arg(bg.name()) + .arg(border.name()) + .arg(t.metrics.radius) + .arg(t.state_ink(is_modified(), is_locked()).name())); +} + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/src/designs/industrial/section.cpp b/MetaUI/qt/src/designs/industrial/section.cpp new file mode 100644 index 0000000..eb657ac --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/section.cpp @@ -0,0 +1,314 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/designs/industrial/section.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include "meta/logger.hpp" + +namespace meta::qt::industrial +{ + +namespace +{ + +/// Object name the body background rule is selected by. +constexpr char kSectionBodyObjectName[] = "MetaIndustrialSectionBody"; + +/** @brief Stylesheet for the header, applied to the button itself. + * + * Deliberately *not* set on the section. A widget carrying a stylesheet has Qt + * draw its background through the style machinery, which overrides anything + * paintEvent puts down -- which is why two attempts at a card background + * produced no visible change at all. With the section stylesheet-free, its + * paintEvent is authoritative again and can draw the card. + * + * The stock header renders as a *checked* QToolButton, which most styles paint + * in the platform highlight colour. That is why an unstyled panel shows blue + * bars between grey rows. + */ +QString header_stylesheet(const Theme &theme) +{ + const Metrics &m = theme.metrics; + + // Transparent by default so the section's single painted card shows through. + // Giving the header its own background makes it a second surface that has to + // be butted against the body, and no amount of spacing tweaking makes two + // surfaces meet cleanly -- there is always a seam or an overlap. + // + // Hover and press still tint, because those are states of the header alone. + // Every state names an explicit background, and it is the same colour the + // section paints its card with. + // + // Relying on "transparent" did not work: if Qt falls back to its own painting + // for any state -- :checked in particular, which every expanded section is -- + // the header comes out a different shade to the card behind it. Stating the + // colour outright in every state means there is nothing left to fall back to. + return QString("QToolButton," + "QToolButton:checked," + "QToolButton:pressed," + "QToolButton:focus {" + " background-color: %1;" + " border: none;" + " outline: none;" + " color: %2;" + " padding: 0px 12px;" + " min-height: %3px;" + " text-align: left;" + " font-weight: bold;" + "}" + "QToolButton:hover { background-color: %4; color: %5; }") + .arg(theme.section_header.name()) + .arg(theme.ink_section_title.name()) + .arg(m.section_header_height) + .arg(theme.section_header_hover.name()) + .arg(theme.ink_primary.name()); +} + +} // namespace + +// --- ClipBox + +ClipBox::ClipBox(QWidget *parent) : QWidget(parent) +{ + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void ClipBox::set_body(QWidget *body) +{ + body_ = body; + if (!body_) return; + + body_->setParent(this); + body_->move(0, 0); + body_->show(); + + // The body's height can change after it is first laid out -- a canvas that + // derives its height from its width is the obvious case. Without this the + // body stays frozen at whatever it measured first and gets cropped. + body_->installEventFilter(this); + + updateGeometry(); +} + +bool ClipBox::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == body_ && event->type() == QEvent::LayoutRequest) + { + body_->setGeometry(0, 0, width(), body_height()); + if (follow_) updateGeometry(); + } + + return QWidget::eventFilter(watched, event); +} + +int ClipBox::body_height() const +{ + if (!body_) return 0; + + // sizeHint() rather than height(): the body is laid out at its natural size + // and never resized, so the hint is what it actually occupies. + const int hint = body_->sizeHint().height(); + return hint > 0 ? hint : body_->height(); +} + +void ClipBox::set_reveal(int px) +{ + follow_ = false; + reveal_ = std::max(0, px); + updateGeometry(); +} + +void ClipBox::follow_body() +{ + follow_ = true; + updateGeometry(); +} + +QSize ClipBox::sizeHint() const +{ + const int w = body_ ? body_->sizeHint().width() : 0; + return QSize(w, follow_ ? body_height() : reveal_); +} + +void ClipBox::resizeEvent(QResizeEvent *event) +{ + // Keep the body at full height whatever this widget's height is. The crop is + // free: a child is clipped to its parent's bounds. + if (body_) body_->setGeometry(0, 0, width(), body_height()); + + QWidget::resizeEvent(event); +} + +// --- Section + +Section::Section(const QString &title, const Theme &theme, QWidget *parent) + : CollapsibleSection(title, parent), theme_(&theme) +{ + toggle_button->setStyleSheet(header_stylesheet(theme)); + + // The header and the body each paint their own background, in the same colour + // the section paints its card with. All three agree, so there is no seam to + // line up. + // + // Painting only the card does not work: both children are opaque and cover + // it. A magenta test card showed through as nothing but a three-pixel line in + // the gap between them, which is exactly what the card is still useful for -- + // it fills that gap, and nothing else. + content->setObjectName(QString::fromLatin1(kSectionBodyObjectName)); + content->setAttribute(Qt::WA_StyledBackground, true); + content->setStyleSheet( + QString("#%1 { background-color: %2; border-bottom-left-radius: %3px;" + " border-bottom-right-radius: %3px; }" + // Stock widgets bring their own labels, and some fill a + // background from the palette, which shows as a pale strip. + "#%1 QLabel { background: transparent; }") + .arg(QString::fromLatin1(kSectionBodyObjectName)) + .arg(theme.section_surface.name()) + .arg(theme.metrics.section_card_radius)); + + // Without this the header is only as wide as its text, which reads as a + // floating pill rather than a section bar. QToolButton defaults to a + // non-expanding horizontal policy. + toggle_button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + toggle_button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + + // Fixed vertically: the base class leaves sections Preferred, which lets the + // panel's QVBoxLayout hand each one a share of the leftover space and + // re-divide it whenever any section changes height. The trailing stretch in + // the panel layout is where slack should go instead. + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + + if (content_layout) + { + const Metrics &m = theme.metrics; + content_layout->setContentsMargins(m.section_body_padding_x, + m.section_body_padding_y, + m.section_body_padding_x, + m.section_body_padding_y); + content_layout->setSpacing(m.section_row_spacing); + } + + // Slot the body into a clip box so the animation never touches its size. + if (auto *outer = qobject_cast(layout())) + { + const Metrics &m = theme.metrics; + // Matches the card inset in paintEvent, so the header and rows sit inside + // the card rather than straddling its edge. + outer->setContentsMargins(m.section_card_margin, + m.section_card_gap / 2, + m.section_card_margin, + m.section_card_gap / 2); + + outer->setSpacing(0); + + outer->removeWidget(content); + + clip_ = new ClipBox(this); + clip_->setAutoFillBackground(false); + clip_->setAttribute(Qt::WA_StyledBackground, false); + clip_->set_body(content); + outer->addWidget(clip_); + } + + animation_ = new QVariantAnimation(this); + animation_->setDuration(theme.metrics.section_ms); + animation_->setEasingCurve(QEasingCurve::OutCubic); + + connect(animation_, + &QVariantAnimation::valueChanged, + this, + [this](const QVariant &v) { clip_->set_reveal(v.toInt()); }); + + connect(animation_, + &QVariantAnimation::finished, + this, + [this]() + { + // Once open, track the body so later content changes are picked up + // rather than being frozen at whatever the animation ended on. + if (expanded_) clip_->follow_body(); + }); +} + +void Section::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + + const Metrics &m = theme_->metrics; + + // One card for the whole section, inset vertically by half the gap at each + // end so consecutive sections are separated by unpainted page. + const QRect card = rect().adjusted(m.section_card_margin, + m.section_card_gap / 2, + -m.section_card_margin, + -m.section_card_gap / 2); + + painter.setPen(Qt::NoPen); + painter.setBrush(theme_->section_surface); + painter.drawRoundedRect(card, m.section_card_radius, m.section_card_radius); +} + +void Section::set_expanded(bool new_state) +{ + const bool was_expanded = expanded_; + expanded_ = new_state; + + toggle_button->setArrowType(new_state ? Qt::DownArrow : Qt::RightArrow); + { + QSignalBlocker blocker(toggle_button); + toggle_button->setChecked(new_state); + } + + if (!clip_) + { + content->setVisible(new_state); + Q_EMIT expanded_state_changed(new_state); + return; + } + + // The first call restores persisted state during construction, before + // anything is on screen. Animating that would play every section open at + // startup, so seat it directly. + if (first_apply_ || was_expanded == new_state) + { + first_apply_ = false; + animation_->stop(); + + if (new_state) + clip_->follow_body(); + else + clip_->set_reveal(0); + + update(); + Q_EMIT expanded_state_changed(new_state); + return; + } + + const int full = clip_->body_height(); + + animation_->stop(); // a running animation ignores a retargeted end value + animation_->setStartValue(new_state ? 0 : full); + animation_->setEndValue(new_state ? full : 0); + animation_->start(); + + update(); + Q_EMIT expanded_state_changed(new_state); +} + +SectionFactory make_section_factory(const Theme &theme) +{ + return [&theme](const QString &title) -> CollapsibleSection * + { return new Section(title, theme); }; +} + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/src/designs/industrial/slider_chrome.cpp b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp new file mode 100644 index 0000000..b1a97a5 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp @@ -0,0 +1,148 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/designs/industrial/slider_chrome.hpp" + +#include +#include + +#include +#include + +namespace meta::qt::industrial +{ + +SliderGeometry SliderGeometry::compute(const Theme &theme, + int width, + int height, + qreal norm) +{ + const Metrics &m = theme.metrics; + + SliderGeometry g; + + const int label_width = int(std::clamp(width * m.label_width_ratio, + m.label_min_width, + m.label_max_width)); + + // The narrow branch keys off this row's own width, not the window's. + const int field_width = width < m.narrow_threshold + ? m.value_field_width_narrow + : m.value_field_width; + + g.label = QRect(0, 0, label_width, height); + + const int x0 = label_width + m.gap; + const int x1 = width - field_width - m.gap; + + g.rail = QRect(x0, + (height - m.rail_height) / 2, + std::max(0, x1 - x0), + m.rail_height); + + const int travel = std::max(0, g.rail.width() - m.thumb_width); + g.thumb = QRect(g.rail.x() + + int(std::round(std::clamp(norm, 0.0, 1.0) * travel)), + (height - m.thumb_height) / 2, + m.thumb_width, + m.thumb_height); + + g.fill = QRect( + g.rail.x(), + g.rail.y(), + std::clamp(g.thumb.center().x() - g.rail.x(), 0, g.rail.width()), + g.rail.height()); + + g.field = QRect(width - field_width, + (height - m.value_field_height) / 2, + field_width, + m.value_field_height); + + return g; +} + +void paint_slider_row(QPainter &painter, + const Theme &theme, + const SliderGeometry &geometry, + const SliderVisual &visual, + int) +{ + painter.setRenderHint(QPainter::Antialiasing, true); + + const Metrics &m = theme.metrics; + + // --- label. Text is the only thing state is allowed to change. + QFont label_font = ui_font(12, false, 1.0); + label_font.setCapitalization(QFont::AllUppercase); + painter.setFont(label_font); + painter.setPen(theme.state_ink(visual.modified, visual.locked)); + painter.drawText(geometry.label, + Qt::AlignLeft | Qt::AlignVCenter, + visual.label); + + if (geometry.rail.width() <= 0) return; + + // --- rail well + painter.setPen(QPen(theme.rail_well_border, 1)); + painter.setBrush(theme.rail_well); + painter.drawRoundedRect(QRectF(geometry.rail).adjusted(0.5, 0.5, -0.5, -0.5), + m.rail_radius, + m.rail_radius); + + // --- fill. Always the group accent; never a state colour. + if (geometry.fill.width() > 0) + { + painter.setPen(Qt::NoPen); + painter.setBrush(theme.rail_fill(visual.category, visual.locked)); + painter.drawRoundedRect( + QRectF(geometry.fill).adjusted(0.5, 0.5, -0.5, -0.5), + m.rail_radius, + m.rail_radius); + } + + // --- thumb + painter.setOpacity(visual.locked ? theme.locked_thumb_alpha : 1.0); + + QLinearGradient metal(geometry.thumb.topLeft(), geometry.thumb.bottomLeft()); + metal.setColorAt(0.0, theme.thumb_top); + metal.setColorAt(1.0, theme.thumb_bottom); + + painter.setPen(QPen(theme.thumb_border, 1)); + painter.setBrush(metal); + painter.drawRoundedRect(QRectF(geometry.thumb).adjusted(0.5, 0.5, -0.5, -0.5), + m.radius, + m.radius); + + // grip notch, 2x8 centred + painter.setPen(Qt::NoPen); + painter.setBrush(theme.thumb_grip); + painter.drawRect(QRect(geometry.thumb.center().x(), + geometry.thumb.center().y() - 3, + 2, + 8)); + + painter.setOpacity(1.0); +} + +QString field_stylesheet(const Theme &theme, + bool editing, + bool modified, + bool locked) +{ + const QColor bg = editing ? theme.field_editing : theme.field; + const QColor border = editing ? theme.accent : theme.field_border; + + return QString("QLineEdit {" + " background: %1;" + " border: 1px solid %2;" + " border-radius: %3px;" + " color: %4;" + " padding-right: 4px;" + "}") + .arg(bg.name()) + .arg(border.name()) + .arg(theme.metrics.radius) + .arg(theme.state_ink(modified, locked).name()); +} + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/src/designs/stock/stock.cpp b/MetaUI/qt/src/designs/stock/stock.cpp new file mode 100644 index 0000000..fa8cdf8 --- /dev/null +++ b/MetaUI/qt/src/designs/stock/stock.cpp @@ -0,0 +1,40 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/designs/stock/stock.hpp" + +#include "stock_internal.hpp" + +#include "meta_qt/ui/design_registry.hpp" +#include "meta_qt/widgets/collapsible_section.hpp" + +namespace meta::qt::stock +{ + +void register_design() +{ + static bool registered = false; + if (registered) return; + registered = true; + + DesignRegistry ®istry = DesignRegistry::instance(); + + // --- Section factory + registry.register_section_factory(kDesignName, + [](const QString &title) + { return new CollapsibleSection(title); }); + + // --- Category widget registrations + register_stock_bool(registry); + register_stock_numeric(registry); + register_stock_string(registry); + register_stock_filesystem(registry); + +#ifdef META_ENABLE_GLM_TYPES + register_stock_glm(registry); +#endif + + register_stock_misc(registry); +} + +} // namespace meta::qt::stock diff --git a/MetaUI/qt/src/designs/stock/stock_bool.cpp b/MetaUI/qt/src/designs/stock/stock_bool.cpp new file mode 100644 index 0000000..aa5bcd8 --- /dev/null +++ b/MetaUI/qt/src/designs/stock/stock_bool.cpp @@ -0,0 +1,181 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "stock_internal.hpp" + +#include +#include +#include +#include + +#include "meta_common.hpp" +#include "meta_qt/designs/stock/stock.hpp" +#include "meta_qt/meta_widget.hpp" + +namespace meta::qt::stock +{ + +namespace +{ + +MetaWidget *render_bool(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + bool &value = attr.value(); + + MetaWidget *widget = make_meta_widget_grid(parent); + auto *layout = dynamic_cast(widget->layout()); + + if (widget_type.empty()) widget_type = "Toggle"; + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "Toggle") + { + auto *button = new QPushButton(label_txt.c_str(), widget); + layout->addWidget(button, 0, 0); + button->setCheckable(true); + + widget->set_sync_from_model([button, &value]() + { button->setChecked(value); }); + widget->sync_widget_from_model(); + + QObject::connect(button, + &QPushButton::toggled, + widget, + [widget, &attr](bool checked) + { + attr.set_from_any(checked); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "BinaryButtons") + { + int row = 0; + if (!label_txt.empty()) + { + QLabel *label = new QLabel(label_txt.c_str(), widget); + layout->addWidget(label, row, 0, 1, 2); + row++; + } + + const std::string label_true = meta::common::try_get( + attr, + meta::keys::ui::label_true, + "True"); + const std::string label_false = meta::common::try_get( + attr, + meta::keys::ui::label_false, + "False"); + + auto *button_true = new QPushButton(QObject::tr(label_true.c_str()), + widget); + auto *button_false = new QPushButton(QObject::tr(label_false.c_str()), + widget); + + layout->addWidget(button_true, row, 0); + layout->addWidget(button_false, row, 1); + + button_true->setCheckable(true); + button_false->setCheckable(true); + + widget->set_sync_from_model( + [button_true, button_false, &value]() + { + button_true->setChecked(value); + button_false->setChecked(!value); + }); + widget->sync_widget_from_model(); + + QObject::connect(button_true, + &QPushButton::clicked, + widget, + [widget, button_true, button_false, &attr]() + { + if (button_true->isChecked()) + { + button_false->setChecked(false); + attr.set_from_any(true); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + } + else + { + button_true->setChecked(true); + } + }); + + QObject::connect(button_false, + &QPushButton::clicked, + widget, + [widget, button_true, button_false, &attr]() + { + if (button_false->isChecked()) + { + button_true->setChecked(false); + attr.set_from_any(false); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + } + else + { + button_false->setChecked(true); + } + }); + } + else if (widget_type == "Checkbox") + { + QCheckBox *checkbox = new QCheckBox(label_txt.c_str(), widget); + layout->addWidget(checkbox, 0, 0); + + widget->set_sync_from_model([checkbox, &value]() + { checkbox->setChecked(value); }); + widget->sync_widget_from_model(); + + QObject::connect(checkbox, + &QCheckBox::toggled, + widget, + [widget, &attr](bool checked) + { + attr.set_from_any(checked); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget), + 0, + 0); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](bool) { widget->sync_widget_from_model(); }); + + return widget; +} + +} // namespace + +void register_stock_bool(DesignRegistry ®istry) +{ + const std::type_index type = std::type_index(typeid(bool)); + registry.add(kDesignName, type, "Toggle", render_bool); + registry.add(kDesignName, type, "Checkbox", render_bool); + registry.add(kDesignName, type, "BinaryButtons", render_bool); + registry.add(kDesignName, type, kAnyWidgetType, render_bool); +} + +} // namespace meta::qt::stock diff --git a/MetaUI/qt/src/designs/stock/stock_filesystem.cpp b/MetaUI/qt/src/designs/stock/stock_filesystem.cpp new file mode 100644 index 0000000..72cb9e2 --- /dev/null +++ b/MetaUI/qt/src/designs/stock/stock_filesystem.cpp @@ -0,0 +1,179 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "stock_internal.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "meta_common.hpp" +#include "meta_qt/designs/stock/stock.hpp" +#include "meta_qt/meta_widget.hpp" + +namespace meta::qt::stock +{ + +namespace +{ + +MetaWidget *render_filesystem(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + const std::string filter = meta::common::file_filter(attr); + const std::string start_dir_meta = meta::common::try_get( + attr, + "ui.start_dir", + ""); + + std::filesystem::path &value = attr.value(); + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (widget_type.empty()) widget_type = "OpenFile"; + + const bool is_open_file = (widget_type == "OpenFile"); + const bool is_save_file = (widget_type == "SaveFile"); + const bool is_directory = (widget_type == "Directory"); + + if (widget_type == "None") + { + return nullptr; + } + else if (is_open_file || is_save_file || is_directory) + { + if (!label_txt.empty()) + { + layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); + } + + auto *row = new QHBoxLayout(); + + auto *line_edit = new QLineEdit(widget); + line_edit->setReadOnly(true); + line_edit->setPlaceholderText(is_directory + ? QObject::tr("No folder selected") + : QObject::tr("No file selected")); + line_edit->setText(QString::fromStdString(value.string())); + + widget->set_sync_from_model( + [line_edit, &value]() + { + const QSignalBlocker blocker(line_edit); + line_edit->setText(QString::fromStdString(value.string())); + }); + + auto *browse_button = new QPushButton(QObject::tr("…"), widget); + browse_button->setFixedWidth(28); + + auto *clear_button = new QPushButton(QObject::tr("✕"), widget); + clear_button->setFixedWidth(24); + clear_button->setToolTip(QObject::tr("Clear")); + + row->addWidget(line_edit); + row->addWidget(browse_button); + row->addWidget(clear_button); + + layout->addLayout(row); + + QObject::connect( + browse_button, + &QPushButton::clicked, + widget, + [&value, + &attr, + widget, + line_edit, + is_save_file, + is_directory, + filter_str = QString::fromStdString(filter), + meta_dir = QString::fromStdString(start_dir_meta)]() + { + const QString start_dir = + !meta_dir.isEmpty() + ? meta_dir + : (!value.empty() + ? QString::fromStdString(value.parent_path().string()) + : QDir::homePath()); + + QString picked; + + if (is_directory) + { + picked = QFileDialog::getExistingDirectory( + widget, + QObject::tr("Select folder"), + start_dir, + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + } + else if (is_save_file) + { + picked = QFileDialog::getSaveFileName(widget, + QObject::tr("Save file"), + start_dir, + filter_str); + } + else + { + picked = QFileDialog::getOpenFileName(widget, + QObject::tr("Open file"), + start_dir, + filter_str); + } + + if (picked.isEmpty()) return; + + attr.set_from_any(std::filesystem::path(picked.toStdString())); + line_edit->setText(picked); + + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(clear_button, + &QPushButton::clicked, + widget, + [&attr, widget, line_edit]() + { + attr.set_from_any(std::filesystem::path{}); + line_edit->clear(); + + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](std::filesystem::path) { widget->sync_widget_from_model(); }); + + return widget; +} + +} // namespace + +void register_stock_filesystem(DesignRegistry ®istry) +{ + const std::type_index type = std::type_index(typeid(std::filesystem::path)); + registry.add(kDesignName, type, "OpenFile", render_filesystem); + registry.add(kDesignName, type, "SaveFile", render_filesystem); + registry.add(kDesignName, type, "Directory", render_filesystem); + registry.add(kDesignName, type, kAnyWidgetType, render_filesystem); +} + +} // namespace meta::qt::stock diff --git a/MetaUI/qt/src/designs/stock/stock_glm.cpp b/MetaUI/qt/src/designs/stock/stock_glm.cpp new file mode 100644 index 0000000..4ce8ef1 --- /dev/null +++ b/MetaUI/qt/src/designs/stock/stock_glm.cpp @@ -0,0 +1,1558 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "stock_internal.hpp" + +#ifdef META_ENABLE_GLM_TYPES + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "meta/core/data_provider.hpp" +#include "meta_common.hpp" +#include "meta_qt/designs/stock/stock.hpp" +#include "meta_qt/meta_widget.hpp" +#include "meta_qt/widgets/points_canvas.hpp" +#include "meta_qt/widgets/power_of_two_spin_box.hpp" +#include "meta_qt/widgets/range_bar.hpp" +#include "meta_qt/widgets/responsive_box.hpp" +#include "meta_qt/widgets/slider_float.hpp" +#include "meta_qt/widgets/vector_canvas.hpp" +#include "meta_qt/widgets/xy_canvas.hpp" + +namespace meta::qt::stock +{ + +namespace +{ + +inline int ceil_power_of_two(int v) +{ + if (v <= 1) return 1; + int p = 1; + while (p < v) + p <<= 1; + return p; +} + +MetaWidget *render_ivec2(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + const int min = meta::common::min(attr); + const int max = meta::common::max(attr); + const int step = meta::common::step(attr); + const bool power_of_two = meta::common::power_of_two(attr); + const float aspect_ratio = meta::common::aspect_ratio(attr); + const bool keep_aspect = (aspect_ratio != 0.f); + + glm::ivec2 &value = attr.value(); + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (widget_type.empty()) widget_type = "Input"; + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "Input") + { + if (!label_txt.empty()) + { + layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); + } + + auto *row = new QHBoxLayout(); + + QSpinBox *spinbox_x = power_of_two ? new PowerOfTwoSpinBox(widget) + : new QSpinBox(widget); + QSpinBox *spinbox_y = power_of_two ? new PowerOfTwoSpinBox(widget) + : new QSpinBox(widget); + + for (auto *sp : {spinbox_x, spinbox_y}) + { + sp->setRange(min, max); + sp->setSingleStep(step); + } + + spinbox_x->setValue(std::clamp(value.x, min, max)); + spinbox_y->setValue(std::clamp(value.y, min, max)); + + if (keep_aspect) + { + spinbox_y->setEnabled(false); + spinbox_y->setRange(int(min / aspect_ratio), int(max / aspect_ratio)); + spinbox_y->setToolTip( + QString(QObject::tr("Aspect ratio x/y = %1")).arg(aspect_ratio)); + + int y = int(std::lround(double(spinbox_x->value()) / aspect_ratio)); + value.x = spinbox_x->value(); + value.y = y; + spinbox_y->setValue(y); + } + + row->addWidget(spinbox_x); + row->addWidget(spinbox_y); + layout->addLayout(row); + + widget->set_sync_from_model( + [spinbox_x, + spinbox_y, + &value, + min, + max, + power_of_two, + keep_aspect, + aspect_ratio]() + { + { + QSignalBlocker b(spinbox_x); + int x = std::clamp(value.x, min, max); + if (power_of_two) x = ceil_power_of_two(x); + spinbox_x->setValue(x); + } + + { + QSignalBlocker b(spinbox_y); + int y = std::clamp(value.y, min, max); + if (keep_aspect) + y = int(std::lround(double(spinbox_x->value()) / aspect_ratio)); + else if (power_of_two) + y = ceil_power_of_two(y); + spinbox_y->setValue(y); + } + }); + + QObject::connect(spinbox_x, + qOverload(&QSpinBox::valueChanged), + widget, + [&attr, + &value, + widget, + spinbox_x, + spinbox_y, + min, + max, + keep_aspect, + aspect_ratio](int v) + { + int x = std::clamp(v, min, max); + int y = value.y; + + if (keep_aspect) + { + y = int(std::lround(double(x) / aspect_ratio)); + QSignalBlocker blocker(spinbox_y); + spinbox_y->setValue(y); + } + + { + QSignalBlocker blocker(spinbox_x); + spinbox_x->setValue(x); + } + + attr.set_from_any(glm::ivec2{x, y}); + + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(spinbox_y, + qOverload(&QSpinBox::valueChanged), + widget, + [&attr, &value, widget, spinbox_y, min, max](int v) + { + int y = std::clamp(v, min, max); + { + QSignalBlocker blocker(spinbox_y); + spinbox_y->setValue(y); + } + + attr.set_from_any(glm::ivec2{value.x, y}); + + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](glm::ivec2) { widget->sync_widget_from_model(); }); + + return widget; +} + +MetaWidget *render_vec2(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + const std::string format = meta::common::format(attr); + const float min = meta::common::min(attr); + const float max = meta::common::max(attr); + const float step = meta::common::step(attr); + const bool show_grid = meta::common::try_get(attr, + "ui.show_grid", + true); + + const std::string x_label = meta::common::try_get(attr, + "ui.label_x", + "x"); + const std::string y_label = meta::common::try_get(attr, + "ui.label_y", + "y"); + const int decimals = meta::common::try_get_format_decimals(format); + + bool locked_xy = false; + if (const auto *p = attr.state().try_value( + meta::keys::state::locked_xy)) + locked_xy = *p; + + glm::vec2 &value = attr.value(); + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (widget_type.empty()) widget_type = "Input"; + + if (!label_txt.empty()) + layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "Input") + { + auto *row = new QHBoxLayout(); + + auto *spinbox_x = new QDoubleSpinBox(widget); + auto *spinbox_y = new QDoubleSpinBox(widget); + + for (auto *sp : {spinbox_x, spinbox_y}) + { + sp->setRange(min, max); + sp->setSingleStep(step); + sp->setDecimals(decimals); + } + + spinbox_x->setValue(std::clamp(value.x, min, max)); + spinbox_y->setValue(std::clamp(value.y, min, max)); + + row->addWidget(spinbox_x); + row->addWidget(spinbox_y); + layout->addLayout(row); + + widget->set_sync_from_model( + [&value, spinbox_x, spinbox_y, min, max]() + { + { + QSignalBlocker b(spinbox_x); + spinbox_x->setValue(std::clamp(value.x, min, max)); + } + { + QSignalBlocker b(spinbox_y); + spinbox_y->setValue(std::clamp(value.y, min, max)); + } + }); + + QObject::connect(spinbox_x, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [&attr, &value, widget, spinbox_x, min, max](double v) + { + float x = std::clamp(static_cast(v), min, max); + { + QSignalBlocker blocker(spinbox_x); + spinbox_x->setValue(x); + } + attr.set_from_any(glm::vec2{x, value.y}); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(spinbox_y, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [&attr, &value, widget, spinbox_y, min, max](double v) + { + float y = std::clamp(static_cast(v), min, max); + { + QSignalBlocker blocker(spinbox_y); + spinbox_y->setValue(y); + } + attr.set_from_any(glm::vec2{value.x, y}); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "XYCanvas") + { + const float min_x = meta::common::try_get(attr, + meta::keys::ui::min_x, + min); + const float max_x = meta::common::try_get(attr, + meta::keys::ui::max_x, + max); + const float min_y = meta::common::try_get(attr, + meta::keys::ui::min_y, + min); + const float max_y = meta::common::try_get(attr, + meta::keys::ui::max_y, + max); + + auto *canvas = + new XYCanvas(value, min_x, max_x, min_y, max_y, show_grid, widget); + layout->addWidget(canvas); + + auto *btn_row = new QHBoxLayout(); + auto *center_btn = new QPushButton(QObject::tr("Center"), widget); + auto *random_btn = new QPushButton(QObject::tr("Random"), widget); + + for (auto *btn : {center_btn, random_btn}) + { + btn->setFixedHeight(22); + btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + btn_row->addWidget(btn); + } + layout->addLayout(btn_row); + + widget->set_sync_from_model( + [&value, canvas]() + { + QSignalBlocker blocker(canvas); + canvas->set_value(value); + }); + + QObject::connect(canvas, + &XYCanvas::value_changed, + widget, + [widget, &attr](glm::vec2) + { + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(canvas, + &XYCanvas::drag_ended, + widget, + [widget, &attr](glm::vec2) + { + Q_EMIT widget->edit_ended(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(center_btn, + &QPushButton::clicked, + widget, + [&attr, min, max, canvas, widget]() + { + const glm::vec2 center = {(min + max) * 0.5f, + (min + max) * 0.5f}; + attr.set_from_any(center); + canvas->set_value(center); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(random_btn, + &QPushButton::clicked, + widget, + [&attr, min, max, canvas, widget]() + { + static std::mt19937 rng{std::random_device{}()}; + std::uniform_real_distribution dist_x(min, max); + std::uniform_real_distribution dist_y(min, max); + const glm::vec2 rv = {dist_x(rng), dist_y(rng)}; + attr.set_from_any(rv); + canvas->set_value(rv); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "RangeBar") + { + bool is_active = true; + if (const auto *p = attr.state().try_value(meta::keys::state::active)) + is_active = *p; + + glm::vec2 last_active_value = is_active ? value : glm::vec2{min, max}; + + auto *bar = new RangeBar(value, min, max, decimals, widget); + + meta::DataProvider range_provider; + if (const auto *mp = attr.metadata().find(meta::keys::ui::data_provider)) + if (const auto *dp = mp->try_cast>()) + range_provider = dp->value(); + + if (range_provider) + { + try + { + auto data = range_provider(); + if (auto histogram = data.get()) + bar->set_histogram(histogram->x, histogram->y); + else + bar->set_histogram({}, {}); + } + catch (...) + { + } + } + + auto *btn_row = new QHBoxLayout(); + auto *toggle_btn = new QPushButton(widget); + auto *reset_btn = new QPushButton(QObject::tr("Full"), widget); + auto *center_btn = new QPushButton(QObject::tr("Center"), widget); + auto *unit_btn = new QPushButton(QObject::tr("[0, 1]"), widget); + + toggle_btn->setCheckable(true); + toggle_btn->setChecked(is_active); + toggle_btn->setText(is_active ? QObject::tr("On") : QObject::tr("Off")); + toggle_btn->setFixedHeight(22); + toggle_btn->setFixedWidth(40); + + for (auto *btn : {reset_btn, center_btn, unit_btn}) + { + btn->setFixedHeight(22); + btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + } + + btn_row->addWidget(toggle_btn); + btn_row->addSpacing(4); + btn_row->addWidget(reset_btn); + btn_row->addWidget(center_btn); + btn_row->addWidget(unit_btn); + + layout->addWidget(bar); + layout->addLayout(btn_row); + + auto set_active = + [&value, bar, reset_btn, center_btn, unit_btn, min, max](bool active) + { + const bool full_domain = value.x <= min && value.y >= max; + bar->setEnabled(active); + reset_btn->setEnabled(active && !full_domain); + center_btn->setEnabled(active && value.y - value.x < max - min); + unit_btn->setEnabled(active); + }; + + set_active(is_active); + + widget->set_sync_from_model( + [&value, &attr, bar, toggle_btn, set_active, widget, range_provider]() + { + bool active = true; + if (const auto *p = attr.state().try_value( + meta::keys::state::active)) + active = *p; + + set_active(active); + + { + QSignalBlocker b(toggle_btn); + toggle_btn->setChecked(active); + toggle_btn->setText(active ? QObject::tr("On") + : QObject::tr("Off")); + } + + { + QSignalBlocker b(bar); + bar->set_value(value); + bar->update(); + } + + if (range_provider && !widget->is_editing()) + { + try + { + auto data = range_provider(); + if (auto histogram = data.get()) + bar->set_histogram(histogram->x, histogram->y); + else + bar->set_histogram({}, {}); + } + catch (...) + { + } + } + }); + + QObject::connect( + toggle_btn, + &QPushButton::toggled, + widget, + [&value, + &attr, + bar, + toggle_btn, + set_active, + widget, + lav = last_active_value](bool active) mutable + { + toggle_btn->setText(active ? QObject::tr("On") : QObject::tr("Off")); + + if (active) + { + attr.set_from_any(lav); + bar->set_value(lav); + } + else + { + lav = value; + attr.set_from_any(glm::vec2{-1.f, 0.f}); + bar->set_value({-1.f, 0.f}); + } + + if (auto *p = attr.state().try_value(meta::keys::state::active)) + *p = active; + + set_active(active); + + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(bar, + &RangeBar::value_changed, + widget, + [widget, set_active](glm::vec2) + { + set_active(true); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + }); + + QObject::connect(bar, + &RangeBar::drag_ended, + widget, + [widget](glm::vec2) { Q_EMIT widget->edit_ended(); }); + + QObject::connect(reset_btn, + &QPushButton::clicked, + widget, + [&attr, min, max, bar, widget, set_active]() + { + bar->set_value({min, max}); + attr.set_from_any(glm::vec2{min, max}); + set_active(true); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect( + center_btn, + &QPushButton::clicked, + widget, + [&value, &attr, min, max, bar, widget, set_active]() + { + const float span = value.y - value.x; + const float mid = (min + max) * 0.5f; + const float lo = std::clamp(mid - span * 0.5f, min, max - span); + bar->set_value({lo, lo + span}); + attr.set_from_any(glm::vec2{lo, lo + span}); + set_active(true); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(unit_btn, + &QPushButton::clicked, + widget, + [&attr, min, max, bar, widget, set_active]() + { + const float lo = std::clamp(0.f, min, max); + const float hi = std::clamp(1.f, min, max); + bar->set_value({lo, hi}); + attr.set_from_any(glm::vec2{lo, hi}); + set_active(true); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "VectorEditor") + { + auto *canvas = new VectorCanvas(value, max, locked_xy, widget); + + auto *canvas_row = new QHBoxLayout(); + canvas_row->addStretch(); + canvas_row->addWidget(canvas); + canvas_row->addStretch(); + layout->addLayout(canvas_row); + + auto *form = new QFormLayout(); + form->setContentsMargins(0, 2, 0, 2); + form->setSpacing(3); + + auto *mag_spin = new QDoubleSpinBox(widget); + mag_spin->setRange(0.0, double(max)); + mag_spin->setDecimals(decimals); + mag_spin->setSingleStep(double(max) / 100.0); + mag_spin->setValue(double(canvas->magnitude())); + mag_spin->setFixedHeight(22); + + auto *angle_spin = new QDoubleSpinBox(widget); + angle_spin->setRange(-360.0, 360.0); + angle_spin->setDecimals(1); + angle_spin->setSingleStep(1.0); + angle_spin->setSuffix("°"); + angle_spin->setValue(double(canvas->angle_deg())); + angle_spin->setEnabled(locked_xy); + angle_spin->setFixedHeight(22); + + form->addRow(QObject::tr("Magnitude"), mag_spin); + form->addRow(QObject::tr("Angle"), angle_spin); + layout->addLayout(form); + + auto *lock_row = new QHBoxLayout(); + auto *lock_cb = new QCheckBox(QObject::tr("Isotropic (kx = ky)"), widget); + lock_cb->setChecked(locked_xy); + lock_row->addStretch(); + lock_row->addWidget(lock_cb); + layout->addLayout(lock_row); + + widget->set_sync_from_model( + [&attr, &value, canvas, mag_spin, angle_spin, lock_cb]() + { + float mag = glm::length(value); + float deg = (mag > 1e-6f) + ? std::atan2(value.y, value.x) * 180.f / float(M_PI) + : 45.f; + bool stored_locked_state = false; + if (const auto *p = attr.state().try_value( + meta::keys::state::locked_xy)) + stored_locked_state = *p; + + { + QSignalBlocker b(canvas); + canvas->set_locked(stored_locked_state); + canvas->set_magnitude(mag); + canvas->set_angle_deg(deg); + } + { + QSignalBlocker b(mag_spin); + mag_spin->setValue(mag); + } + { + QSignalBlocker b(angle_spin); + angle_spin->setValue(deg); + angle_spin->setEnabled(!stored_locked_state); + } + { + QSignalBlocker b(lock_cb); + lock_cb->setChecked(stored_locked_state); + } + }); + + QObject::connect(canvas, + &VectorCanvas::magnitude_changed, + widget, + [&attr, mag_spin](float mag) + { + QSignalBlocker b(mag_spin); + mag_spin->setValue(double(mag)); + }); + + QObject::connect(canvas, + &VectorCanvas::angle_changed, + widget, + [&attr, angle_spin](float deg) + { + QSignalBlocker b(angle_spin); + angle_spin->setValue(double(deg)); + }); + + QObject::connect(mag_spin, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [canvas](double v) { canvas->set_magnitude(float(v)); }); + + QObject::connect(angle_spin, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [canvas](double v) { canvas->set_angle_deg(float(v)); }); + + QObject::connect(lock_cb, + &QCheckBox::toggled, + widget, + [&attr, canvas, angle_spin](bool checked) + { + canvas->set_locked(checked); + angle_spin->setEnabled(!checked); + attr.state() + .try_add(meta::keys::state::locked_xy, checked) + ->value() = checked; + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(canvas, + &VectorCanvas::value_changed, + widget, + [&attr, widget](glm::vec2) + { + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(canvas, + &VectorCanvas::drag_ended, + widget, + [&attr, widget](glm::vec2) + { + Q_EMIT widget->edit_ended(); + attr.value_changed.notify(attr.value()); + }); + + auto spinbox_commit = [&attr, widget]() + { + Q_EMIT widget->edit_ended(); + attr.value_changed.notify(attr.value()); + }; + + QObject::connect(mag_spin, + &QDoubleSpinBox::editingFinished, + widget, + spinbox_commit); + QObject::connect(angle_spin, + &QDoubleSpinBox::editingFinished, + widget, + spinbox_commit); + + QObject::connect(lock_cb, + &QCheckBox::toggled, + widget, + [&attr, widget](bool) + { + Q_EMIT widget->edit_ended(); + attr.value_changed.notify(attr.value()); + }); + } + else if (widget_type == "LinkedSliders") + { + auto *row = new QHBoxLayout(); + row->setSpacing(4); + + auto *slider_x = new SliderFloat(x_label, + value.x, + min, + max, + false, + format, + false, + widget); + auto *slider_y = new SliderFloat(y_label, + value.y, + min, + max, + false, + format, + false, + widget); + + slider_x->set_value(value.x); + slider_y->set_value(value.y); + + auto *lock_btn = new QPushButton(QObject::tr("X=Y"), widget); + lock_btn->setCheckable(true); + lock_btn->setChecked(locked_xy); + lock_btn->setFixedWidth(36); + lock_btn->setFixedHeight(slider_x->sizeHint().height()); + lock_btn->setToolTip(QObject::tr("Lock X = Y")); + + auto update_lock_style = [lock_btn](bool locked) + { + lock_btn->setProperty("locked", locked); + lock_btn->setStyleSheet(locked ? "font-weight: bold;" + : "font-weight: normal;"); + }; + update_lock_style(locked_xy); + + if (locked_xy) + { + value.y = value.x; + slider_y->set_value(value.x); + slider_y->setEnabled(false); + } + + auto *pair = new ResponsiveBox(widget); + pair->set_spacing(4); + pair->add_widget(slider_x, 1); + pair->add_widget(slider_y, 1); + + row->addWidget(pair, 1); + row->addWidget(lock_btn, 0); + layout->addLayout(row); + + widget->set_sync_from_model( + [&attr, widget_type, &value, slider_x, slider_y, lock_btn]() + { + { + QSignalBlocker b(slider_x); + slider_x->set_value(value.x); + } + { + QSignalBlocker b(slider_y); + slider_y->set_value(value.y); + } + bool stored_locked_state = false; + if (const auto *p = attr.state().try_value( + meta::keys::state::locked_xy)) + stored_locked_state = *p; + + { + QSignalBlocker b(lock_btn); + lock_btn->setChecked(stored_locked_state); + } + + slider_y->setEnabled(!lock_btn->isChecked()); + }); + + QObject::connect(slider_x, + &SliderFloat::value_changed, + widget, + [&attr, &value, slider_x, slider_y, lock_btn, widget]() + { + value.x = slider_x->get_value(); + if (lock_btn->isChecked()) + { + value.y = value.x; + QSignalBlocker b(slider_y); + slider_y->set_value(value.x); + } + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(slider_x, + &SliderFloat::edit_ended, + widget, + [&attr, &value, slider_x, widget]() + { + value.x = slider_x->get_value(); + Q_EMIT widget->edit_ended(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(slider_y, + &SliderFloat::value_changed, + widget, + [&attr, &value, slider_y, widget]() + { + value.y = slider_y->get_value(); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(slider_y, + &SliderFloat::edit_ended, + widget, + [&attr, &value, slider_y, widget]() + { + value.y = slider_y->get_value(); + Q_EMIT widget->edit_ended(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(lock_btn, + &QPushButton::toggled, + widget, + [&attr, + &value, + slider_x, + slider_y, + lock_btn, + update_lock_style, + widget](bool checked) + { + update_lock_style(checked); + slider_y->setEnabled(!checked); + + if (checked) + { + value.y = value.x; + QSignalBlocker b(slider_y); + slider_y->set_value(value.x); + } + + attr.state() + .try_add(meta::keys::state::locked_xy, checked) + ->value() = checked; + + attr.value_changed.notify(attr.value()); + Q_EMIT widget->edit_ended(); + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](glm::vec2) { widget->sync_widget_from_model(); }); + + return widget; +} + +MetaWidget *render_vec3(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + const std::string format = meta::common::format(attr); + const float min = meta::common::min(attr); + const float max = meta::common::max(attr); + const float step = meta::common::step(attr); + const int decimals = meta::common::try_get_format_decimals(format); + + glm::vec3 &value = attr.value(); + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (widget_type.empty()) widget_type = "Input"; + + if (!label_txt.empty()) + layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "Input") + { + auto *row = new QHBoxLayout(); + auto *spinbox_x = new QDoubleSpinBox(widget); + auto *spinbox_y = new QDoubleSpinBox(widget); + auto *spinbox_z = new QDoubleSpinBox(widget); + + for (auto *sp : {spinbox_x, spinbox_y, spinbox_z}) + { + sp->setRange(min, max); + sp->setSingleStep(step); + sp->setDecimals(decimals); + } + + spinbox_x->setValue(std::clamp(value.x, min, max)); + spinbox_y->setValue(std::clamp(value.y, min, max)); + spinbox_z->setValue(std::clamp(value.z, min, max)); + + row->addWidget(spinbox_x); + row->addWidget(spinbox_y); + row->addWidget(spinbox_z); + layout->addLayout(row); + + widget->set_sync_from_model( + [&value, spinbox_x, spinbox_y, spinbox_z, min, max]() + { + { + QSignalBlocker b(spinbox_x); + spinbox_x->setValue(std::clamp(value.x, min, max)); + } + { + QSignalBlocker b(spinbox_y); + spinbox_y->setValue(std::clamp(value.y, min, max)); + } + { + QSignalBlocker b(spinbox_z); + spinbox_z->setValue(std::clamp(value.z, min, max)); + } + }); + + QObject::connect(spinbox_x, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [&attr, &value, widget, spinbox_x, min, max](double v) + { + float x = std::clamp(static_cast(v), min, max); + { + QSignalBlocker blocker(spinbox_x); + spinbox_x->setValue(x); + } + attr.set_from_any(glm::vec3{x, value.y, value.z}); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(spinbox_y, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [&attr, &value, widget, spinbox_y, min, max](double v) + { + float y = std::clamp(static_cast(v), min, max); + { + QSignalBlocker blocker(spinbox_y); + spinbox_y->setValue(y); + } + attr.set_from_any(glm::vec3{value.x, y, value.z}); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(spinbox_z, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [&attr, &value, widget, spinbox_z, min, max](double v) + { + float z = std::clamp(static_cast(v), min, max); + { + QSignalBlocker blocker(spinbox_z); + spinbox_z->setValue(z); + } + attr.set_from_any(glm::vec3{value.x, value.y, z}); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "ColorPicker") + { + auto *button = new QPushButton(widget); + button->setAutoFillBackground(true); + + auto update_color = [button](const glm::vec3 &color) + { + const int r = static_cast( + std::clamp(color.r * 255.0f, 0.0f, 255.0f)); + const int g = static_cast( + std::clamp(color.g * 255.0f, 0.0f, 255.0f)); + const int b = static_cast( + std::clamp(color.b * 255.0f, 0.0f, 255.0f)); + + const QString style = QString("background-color: rgb(%1, %2, %3);" + "border: 1px solid #555;" + "border-radius: 4px;" + "min-height: 24px;") + .arg(r) + .arg(g) + .arg(b); + button->setStyleSheet(style); + }; + + update_color(value); + layout->addWidget(button); + + widget->set_sync_from_model([&value, update_color]() + { update_color(value); }); + + QObject::connect(button, + &QPushButton::clicked, + widget, + [&attr, &value, widget, update_color]() + { + const QColor initial_color = QColor::fromRgbF( + std::clamp(value.r, 0.0f, 1.0f), + std::clamp(value.g, 0.0f, 1.0f), + std::clamp(value.b, 0.0f, 1.0f)); + + const QColor color = QColorDialog::getColor( + initial_color, + widget, + "Select Color"); + + if (color.isValid()) + { + const glm::vec3 new_color(color.redF(), + color.greenF(), + color.blueF()); + attr.set_from_any(new_color); + update_color(new_color); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + } + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](glm::vec3) { widget->sync_widget_from_model(); }); + + return widget; +} + +MetaWidget *render_vec4(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + const std::string format = meta::common::format(attr); + const float min = meta::common::min(attr); + const float max = meta::common::max(attr); + const float step = meta::common::step(attr); + const int decimals = meta::common::try_get_format_decimals(format); + + glm::vec4 &value = attr.value(); + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (widget_type.empty()) widget_type = "Input"; + + if (!label_txt.empty()) + layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "Input") + { + auto *row = new QHBoxLayout(); + auto *spinbox_x = new QDoubleSpinBox(widget); + auto *spinbox_y = new QDoubleSpinBox(widget); + auto *spinbox_z = new QDoubleSpinBox(widget); + auto *spinbox_w = new QDoubleSpinBox(widget); + + for (auto *sp : {spinbox_x, spinbox_y, spinbox_z, spinbox_w}) + { + sp->setRange(min, max); + sp->setSingleStep(step); + sp->setDecimals(decimals); + } + + spinbox_x->setValue(std::clamp(value.x, min, max)); + spinbox_y->setValue(std::clamp(value.y, min, max)); + spinbox_z->setValue(std::clamp(value.z, min, max)); + spinbox_w->setValue(std::clamp(value.w, min, max)); + + row->addWidget(spinbox_x); + row->addWidget(spinbox_y); + row->addWidget(spinbox_z); + row->addWidget(spinbox_w); + layout->addLayout(row); + + widget->set_sync_from_model( + [&value, spinbox_x, spinbox_y, spinbox_z, spinbox_w, min, max]() + { + { + QSignalBlocker b(spinbox_x); + spinbox_x->setValue(std::clamp(value.x, min, max)); + } + { + QSignalBlocker b(spinbox_y); + spinbox_y->setValue(std::clamp(value.y, min, max)); + } + { + QSignalBlocker b(spinbox_z); + spinbox_z->setValue(std::clamp(value.z, min, max)); + } + { + QSignalBlocker b(spinbox_w); + spinbox_w->setValue(std::clamp(value.w, min, max)); + } + }); + + QObject::connect(spinbox_x, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [&attr, &value, widget, spinbox_x, min, max](double v) + { + float x = std::clamp(static_cast(v), min, max); + { + QSignalBlocker blocker(spinbox_x); + spinbox_x->setValue(x); + } + attr.set_from_any( + glm::vec4{x, value.y, value.z, value.w}); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(spinbox_y, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [&attr, &value, widget, spinbox_y, min, max](double v) + { + float y = std::clamp(static_cast(v), min, max); + { + QSignalBlocker blocker(spinbox_y); + spinbox_y->setValue(y); + } + attr.set_from_any( + glm::vec4{value.x, y, value.z, value.w}); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(spinbox_z, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [&attr, &value, widget, spinbox_z, min, max](double v) + { + float z = std::clamp(static_cast(v), min, max); + { + QSignalBlocker blocker(spinbox_z); + spinbox_z->setValue(z); + } + attr.set_from_any( + glm::vec4{value.x, value.y, z, value.w}); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + QObject::connect(spinbox_w, + qOverload(&QDoubleSpinBox::valueChanged), + widget, + [&attr, &value, widget, spinbox_w, min, max](double v) + { + float w = std::clamp(static_cast(v), min, max); + { + QSignalBlocker blocker(spinbox_w); + spinbox_w->setValue(w); + } + attr.set_from_any( + glm::vec4{value.x, value.y, value.z, w}); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "ColorPicker") + { + auto *button = new QPushButton(widget); + button->setAutoFillBackground(true); + + auto update_color = [button](const glm::vec4 &color) + { + const int r = static_cast( + std::clamp(color.r * 255.0f, 0.0f, 255.0f)); + const int g = static_cast( + std::clamp(color.g * 255.0f, 0.0f, 255.0f)); + const int b = static_cast( + std::clamp(color.b * 255.0f, 0.0f, 255.0f)); + const float a = std::clamp(color.a, 0.0f, 1.0f); + + const QString style = QString("background-color: rgba(%1, %2, %3, %4);" + "border: 1px solid #555;" + "border-radius: 4px;" + "min-height: 24px;") + .arg(r) + .arg(g) + .arg(b) + .arg(a); + button->setStyleSheet(style); + }; + + update_color(value); + layout->addWidget(button); + + widget->set_sync_from_model([&value, update_color]() + { update_color(value); }); + + QObject::connect(button, + &QPushButton::clicked, + widget, + [&attr, &value, widget, update_color]() + { + const QColor initial_color = QColor::fromRgbF( + std::clamp(value.r, 0.0f, 1.0f), + std::clamp(value.g, 0.0f, 1.0f), + std::clamp(value.b, 0.0f, 1.0f), + std::clamp(value.a, 0.0f, 1.0f)); + + const QColor color = QColorDialog::getColor( + initial_color, + widget, + "Select Color", + QColorDialog::ShowAlphaChannel); + + if (color.isValid()) + { + const glm::vec4 new_color(color.redF(), + color.greenF(), + color.blueF(), + color.alphaF()); + attr.set_from_any(new_color); + update_color(new_color); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + } + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](glm::vec4) { widget->sync_widget_from_model(); }); + + return widget; +} + +MetaWidget *render_vec_glm_vec3(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast> &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + + const float min_x = meta::common::try_get(attr, + meta::keys::ui::min_x, + 0.f); + const float max_x = meta::common::try_get(attr, + meta::keys::ui::max_x, + 1.f); + const float min_y = meta::common::try_get(attr, + meta::keys::ui::min_y, + 0.f); + const float max_y = meta::common::try_get(attr, + meta::keys::ui::max_y, + 1.f); + const float z_step = meta::common::try_get(attr, "ui.z_step", 0.05f); + const bool closed = meta::common::try_get(attr, + meta::keys::ui::closed, + false); + + std::vector &value = attr.value(); + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (widget_type.empty()) widget_type = "PointsEditor"; + + const bool is_points = (widget_type == "PointsEditor"); + const bool is_path = (widget_type == "PathEditor"); + + if (widget_type == "None") + { + return nullptr; + } + else if (is_points || is_path) + { + if (!label_txt.empty()) + layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); + + auto *canvas = new PointsCanvas(value, + min_x, + max_x, + min_y, + max_y, + z_step, + is_path ? PointsCanvas::Mode::Path + : PointsCanvas::Mode::Points, + closed, + widget); + layout->addWidget(canvas); + + meta::DataProvider points_provider; + if (const auto *mp = attr.metadata().find(meta::keys::ui::data_provider)) + if (const auto *dp = mp->try_cast>()) + points_provider = dp->value(); + + if (points_provider) + { + try + { + auto data = points_provider(); + if (auto img = data.get()) + if (img->width > 0 && img->height > 0 && !img->pixels.empty()) + canvas->set_background_image(img->pixels, + img->width, + img->height, + img->channels); + } + catch (...) + { + } + } + + auto *toolbar = new QHBoxLayout(); + + auto *clear_btn = new QPushButton(QObject::tr("Clear"), widget); + clear_btn->setFixedHeight(22); + clear_btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + toolbar->addWidget(clear_btn); + + auto *rand_btn = new QPushButton(QObject::tr("Randomize"), widget); + rand_btn->setFixedHeight(22); + rand_btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + toolbar->addWidget(rand_btn); + + auto *csv_btn = new QPushButton(QObject::tr("From CSV…"), widget); + csv_btn->setFixedHeight(22); + csv_btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + toolbar->addWidget(csv_btn); + + layout->addLayout(toolbar); + + widget->set_sync_from_model( + [&value, canvas, widget, points_provider]() + { + QSignalBlocker blocker(canvas); + canvas->set_points(value); + + if (points_provider && !widget->is_editing()) + { + try + { + auto data = points_provider(); + if (auto img = data.get()) + if (img->width > 0 && img->height > 0 && !img->pixels.empty()) + canvas->set_background_image(img->pixels, + img->width, + img->height, + img->channels); + } + catch (...) + { + } + } + }); + + QObject::connect(canvas, + &PointsCanvas::points_changed, + widget, + [&attr, widget]() + { + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(canvas, + &PointsCanvas::drag_ended, + widget, + [&attr, widget]() + { + Q_EMIT widget->edit_ended(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(clear_btn, + &QPushButton::clicked, + canvas, + &PointsCanvas::clear_all); + + QObject::connect(rand_btn, + &QPushButton::clicked, + widget, + [&attr, &value, canvas]() + { + canvas->randomize(value.size()); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(csv_btn, + &QPushButton::clicked, + widget, + [&attr, canvas, widget]() + { + const QString path = QFileDialog::getOpenFileName( + widget, + QObject::tr("Load points from CSV"), + QDir::homePath(), + QObject::tr("CSV files (*.csv);;All Files (*)"), + nullptr, + QFileDialog::DontUseNativeDialog); + if (!path.isEmpty()) + { + canvas->load_csv(path); + attr.value_changed.notify(attr.value()); + } + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](std::vector) { widget->sync_widget_from_model(); }); + + return widget; +} + +} // namespace + +void register_stock_glm(DesignRegistry ®istry) +{ + registry.add(kDesignName, + std::type_index(typeid(glm::ivec2)), + kAnyWidgetType, + render_ivec2); + + const std::type_index type_vec2 = std::type_index(typeid(glm::vec2)); + registry.add(kDesignName, type_vec2, "Input", render_vec2); + registry.add(kDesignName, type_vec2, "XYCanvas", render_vec2); + registry.add(kDesignName, type_vec2, "VectorEditor", render_vec2); + registry.add(kDesignName, type_vec2, "LinkedSliders", render_vec2); + registry.add(kDesignName, type_vec2, "RangeBar", render_vec2); + registry.add(kDesignName, type_vec2, kAnyWidgetType, render_vec2); + + const std::type_index type_vec3 = std::type_index(typeid(glm::vec3)); + registry.add(kDesignName, type_vec3, "Input", render_vec3); + registry.add(kDesignName, type_vec3, "ColorPicker", render_vec3); + registry.add(kDesignName, type_vec3, kAnyWidgetType, render_vec3); + + const std::type_index type_vec4 = std::type_index(typeid(glm::vec4)); + registry.add(kDesignName, type_vec4, "Input", render_vec4); + registry.add(kDesignName, type_vec4, "ColorPicker", render_vec4); + registry.add(kDesignName, type_vec4, kAnyWidgetType, render_vec4); + + const std::type_index type_vec_vec3 = std::type_index( + typeid(std::vector)); + registry.add(kDesignName, type_vec_vec3, "PointsEditor", render_vec_glm_vec3); + registry.add(kDesignName, type_vec_vec3, "PathEditor", render_vec_glm_vec3); + registry.add(kDesignName, type_vec_vec3, kAnyWidgetType, render_vec_glm_vec3); +} + +} // namespace meta::qt::stock + +#endif diff --git a/MetaUI/qt/src/designs/stock/stock_internal.hpp b/MetaUI/qt/src/designs/stock/stock_internal.hpp new file mode 100644 index 0000000..f4b8947 --- /dev/null +++ b/MetaUI/qt/src/designs/stock/stock_internal.hpp @@ -0,0 +1,21 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include "meta_qt/ui/design_registry.hpp" + +namespace meta::qt::stock +{ + +void register_stock_bool(DesignRegistry ®istry); +void register_stock_numeric(DesignRegistry ®istry); +void register_stock_string(DesignRegistry ®istry); +void register_stock_filesystem(DesignRegistry ®istry); + +#ifdef META_ENABLE_GLM_TYPES +void register_stock_glm(DesignRegistry ®istry); +#endif + +void register_stock_misc(DesignRegistry ®istry); + +} // namespace meta::qt::stock diff --git a/MetaUI/qt/src/designs/stock/stock_misc.cpp b/MetaUI/qt/src/designs/stock/stock_misc.cpp new file mode 100644 index 0000000..62b0496 --- /dev/null +++ b/MetaUI/qt/src/designs/stock/stock_misc.cpp @@ -0,0 +1,482 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "stock_internal.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +#include "meta_common.hpp" +#include "meta_qt/designs/stock/stock.hpp" +#include "meta_qt/meta_widget.hpp" +#include "meta_qt/widgets/array_canvas.hpp" +#include "meta_qt/widgets/curve_canvas.hpp" +#include "meta_qt/widgets/gradient_picker.hpp" +#include "meta_qt/widgets/points_canvas.hpp" + +#ifdef META_ENABLE_COLOR_GRADIENT_TYPES +#include "meta/ext/color_gradient/color_gradient.hpp" +#endif + +#ifdef META_ENABLE_ARRAY_TYPES +#include "meta/core/data_provider.hpp" +#include "meta/ext/array/array.hpp" +#endif + +namespace meta::qt::stock +{ + +namespace +{ + +MetaWidget *render_vec_float(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast> &>(abstract_attr); + std::vector &value = attr.value(); + const int default_size = value.size() ? value.size() : 16; + + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + + const int curve_size = meta::common::try_get(attr, + "ui.curve_size", + default_size); + const float min_x = meta::common::try_get(attr, + meta::keys::ui::min_x, + 0.f); + const float max_x = meta::common::try_get(attr, + meta::keys::ui::max_x, + 1.f); + const float min_y = meta::common::try_get(attr, + meta::keys::ui::min_y, + 0.f); + const float max_y = meta::common::try_get(attr, + meta::keys::ui::max_y, + 1.f); + + if (widget_type.empty()) widget_type = "CurveEditor"; + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (!label_txt.empty()) + layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "CurveEditor") + { + auto *canvas = + new CurveCanvas(value, curve_size, min_x, max_x, min_y, max_y, widget); + layout->addWidget(canvas); + + auto *btn_row = new QHBoxLayout(); + auto *reset_btn = new QPushButton(QObject::tr("Reset"), widget); + reset_btn->setFixedHeight(22); + reset_btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + btn_row->addStretch(); + btn_row->addWidget(reset_btn); + layout->addLayout(btn_row); + + widget->set_sync_from_model( + [canvas]() + { + const QSignalBlocker blocker(canvas); + canvas->update(); + }); + + QObject::connect(canvas, + &CurveCanvas::curve_changed, + widget, + [widget, &attr]() + { + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(canvas, + &CurveCanvas::drag_ended, + widget, + [widget, &attr]() + { + Q_EMIT widget->edit_ended(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(reset_btn, + &QPushButton::clicked, + widget, + [&attr, curve_size, min_y, max_y, canvas, widget]() + { + std::vector new_value; + new_value.reserve(curve_size); + + for (int i = 0; i < curve_size; ++i) + { + const float t = float(i) / float(curve_size - 1); + new_value.push_back(min_y + t * (max_y - min_y)); + } + + attr.set_from_any(new_value); + canvas->reset_to_value(); + + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](std::vector) { widget->sync_widget_from_model(); }); + + return widget; +} + +#ifdef META_ENABLE_COLOR_GRADIENT_TYPES +MetaWidget *render_color_gradient(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + + meta::ColorGradient &cga = attr.value(); + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (widget_type.empty()) widget_type = "GradientEditor"; + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "GradientEditor") + { + if (!label_txt.empty()) + layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); + + const auto *p_presets = attr.metadata().try_value( + meta::keys::ui::presets); + + auto *picker = new GradientPicker(cga.value(), + p_presets ? p_presets->presets + : std::vector{}, + widget); + layout->addWidget(picker); + + widget->set_sync_from_model( + [picker]() + { + picker->update_bar(); + picker->update(); + }); + + QObject::connect(picker, + &GradientPicker::value_changed, + widget, + [&attr, widget]() + { + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(picker, + &GradientPicker::edit_ended, + widget, + [&attr, widget]() + { + Q_EMIT widget->edit_ended(); + attr.value_changed.notify(attr.value()); + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](meta::ColorGradient) { widget->sync_widget_from_model(); }); + + return widget; +} +#endif + +#ifdef META_ENABLE_ARRAY_TYPES + +inline std::vector resample_bilinear_array(const std::vector &src, + int src_w, + int src_h, + int dst_w, + int dst_h) +{ + if (src.empty() || src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0) + return std::vector(static_cast(dst_w * dst_h), 0.f); + + auto at = [&](int x, int y) -> float + { + x = std::clamp(x, 0, src_w - 1); + y = std::clamp(y, 0, src_h - 1); + return src[static_cast(y * src_w + x)]; + }; + + std::vector dst(static_cast(dst_w * dst_h)); + const float x_scale = static_cast(src_w) / static_cast(dst_w); + const float y_scale = static_cast(src_h) / static_cast(dst_h); + + for (int j = 0; j < dst_h; ++j) + { + for (int i = 0; i < dst_w; ++i) + { + const float gx = (static_cast(i) + 0.5f) * x_scale - 0.5f; + const float gy = (static_cast(j) + 0.5f) * y_scale - 0.5f; + + const int gxi = static_cast(std::floor(gx)); + const int gyi = static_cast(std::floor(gy)); + + const float tx = gx - static_cast(gxi); + const float ty = gy - static_cast(gyi); + + const float c00 = at(gxi, gyi); + const float c10 = at(gxi + 1, gyi); + const float c01 = at(gxi, gyi + 1); + const float c11 = at(gxi + 1, gyi + 1); + + const float top = (1.f - tx) * c00 + tx * c10; + const float bot = (1.f - tx) * c01 + tx * c11; + + dst[static_cast(j * dst_w + i)] = (1.f - ty) * top + ty * bot; + } + } + return dst; +} + +inline float cubic_interpolate(float p0, float p1, float p2, float p3, float t) +{ + const float a = -0.5f * p0 + 1.5f * p1 - 1.5f * p2 + 0.5f * p3; + const float b = p0 - 2.5f * p1 + 2.0f * p2 - 0.5f * p3; + const float c = -0.5f * p0 + 0.5f * p2; + const float d = p1; + return a * t * t * t + b * t * t + c * t + d; +} + +inline std::vector resample_bicubic_array(const std::vector &src, + int src_w, + int src_h, + int dst_w, + int dst_h) +{ + if (src.empty() || src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0) + return std::vector(static_cast(dst_w * dst_h), 0.f); + + auto at = [&](int x, int y) -> float + { + x = std::clamp(x, 0, src_w - 1); + y = std::clamp(y, 0, src_h - 1); + return src[static_cast(y * src_w + x)]; + }; + + std::vector dst(static_cast(dst_w * dst_h)); + const float x_scale = static_cast(src_w) / static_cast(dst_w); + const float y_scale = static_cast(src_h) / static_cast(dst_h); + + for (int j = 0; j < dst_h; ++j) + { + for (int i = 0; i < dst_w; ++i) + { + const float gx = (static_cast(i) + 0.5f) * x_scale - 0.5f; + const float gy = (static_cast(j) + 0.5f) * y_scale - 0.5f; + + const int gxi = static_cast(std::floor(gx)); + const int gyi = static_cast(std::floor(gy)); + + const float tx = gx - static_cast(gxi); + const float ty = gy - static_cast(gyi); + + float rows[4]; + for (int m = 0; m < 4; ++m) + { + const int row_y = gyi - 1 + m; + rows[m] = cubic_interpolate(at(gxi - 1, row_y), + at(gxi, row_y), + at(gxi + 1, row_y), + at(gxi + 2, row_y), + tx); + } + + dst[static_cast(j * dst_w + i)] = cubic_interpolate(rows[0], + rows[1], + rows[2], + rows[3], + ty); + } + } + return dst; +} + +MetaWidget *render_array(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (widget_type.empty()) widget_type = "ArrayEditor"; + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "ArrayEditor") + { + int canvas_w = 128; + int canvas_h = 128; + + if (const auto *val = attr.metadata().try_value(meta::keys::ui::width)) + canvas_w = *val; + if (const auto *val = attr.metadata().try_value( + meta::keys::ui::height)) + canvas_h = *val; + + auto *canvas = new ArrayCanvas(label_txt, canvas_w, canvas_h, widget); + layout->addWidget(canvas); + + widget->set_sync_from_model( + [canvas, widget, &attr]() + { + if (widget->is_editing()) return; + + auto const &arr = attr.value(); + std::vector data = arr.vector; + + data = resample_bilinear_array(data, + arr.shape.x, + arr.shape.y, + canvas->get_field_width(), + canvas->get_field_height()); + canvas->set_field_data(data); + }); + + widget->sync_widget_from_model(); + + meta::DataProvider data_provider; + if (const auto *mp = attr.metadata().find(meta::keys::ui::data_provider)) + if (const auto *dp = mp->try_cast>()) + data_provider = dp->value(); + + if (data_provider) + { + try + { + auto data = data_provider(); + if (auto img = data.get()) + if (img->width > 0 && img->height > 0 && !img->pixels.empty()) + canvas->set_background_image(img->pixels, + img->width, + img->height, + img->channels); + } + catch (...) + { + } + } + + QObject::connect(canvas, + &ArrayCanvas::value_changed, + widget, + [&attr, canvas, widget]() + { + Q_EMIT widget->edit_started(); + + auto const &cdata = canvas->get_field_data(); + auto &arr = attr.value(); + arr.vector = resample_bicubic_array( + cdata, + canvas->get_field_width(), + canvas->get_field_height(), + arr.shape.x, + arr.shape.y); + + Q_EMIT widget->value_changed(); + attr.value_changed.notify(attr.value()); + }); + + QObject::connect(canvas, + &ArrayCanvas::edit_ended, + widget, + [&attr, canvas, widget]() + { + auto const &cdata = canvas->get_field_data(); + auto &arr = attr.value(); + arr.vector = resample_bicubic_array( + cdata, + canvas->get_field_width(), + canvas->get_field_height(), + arr.shape.x, + arr.shape.y); + + Q_EMIT widget->edit_ended(); + attr.value_changed.notify(attr.value()); + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](meta::Array) { widget->sync_widget_from_model(); }); + + return widget; +} +#endif + +} // namespace + +void register_stock_misc(DesignRegistry ®istry) +{ + registry.add(kDesignName, + std::type_index(typeid(std::vector)), + kAnyWidgetType, + render_vec_float); + +#ifdef META_ENABLE_COLOR_GRADIENT_TYPES + registry.add(kDesignName, + std::type_index(typeid(meta::ColorGradient)), + kAnyWidgetType, + render_color_gradient); +#endif + +#ifdef META_ENABLE_ARRAY_TYPES + registry.add(kDesignName, + std::type_index(typeid(meta::Array)), + kAnyWidgetType, + render_array); +#endif +} + +} // namespace meta::qt::stock diff --git a/MetaUI/qt/src/designs/stock/stock_numeric.cpp b/MetaUI/qt/src/designs/stock/stock_numeric.cpp new file mode 100644 index 0000000..a57969e --- /dev/null +++ b/MetaUI/qt/src/designs/stock/stock_numeric.cpp @@ -0,0 +1,459 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "stock_internal.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "meta_common.hpp" +#include "meta_qt/designs/stock/stock.hpp" +#include "meta_qt/meta_widget.hpp" +#include "meta_qt/widgets/slider_float.hpp" +#include "meta_qt/widgets/slider_int.hpp" + +namespace meta::qt::stock +{ + +namespace +{ + +MetaWidget *render_float(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + const std::string format = meta::common::format(attr); + const float min = meta::common::min(attr); + const float max = meta::common::max(attr); + const float step = meta::common::step(attr); + const bool plus_minus = meta::common::try_get(attr, + "ui.plus_minus", + false); + const bool log_scale = meta::common::try_get(attr, + meta::keys::ui::log_scale, + false); + + float &value = attr.value(); + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (!label_txt.empty() && widget_type != "SliderFloat") + { + QLabel *label = new QLabel(label_txt.c_str(), widget); + layout->addWidget(label); + } + + if (widget_type.empty()) widget_type = "Input"; + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "Input") + { + auto *spinbox = new QDoubleSpinBox(widget); + spinbox->setMinimum(min); + spinbox->setMaximum(max); + spinbox->setSingleStep(step); + spinbox->setValue(std::clamp(value, min, max)); + spinbox->setDecimals(meta::common::try_get_format_decimals(format)); + + layout->addWidget(spinbox); + + widget->set_sync_from_model( + [spinbox, &value]() + { + const QSignalBlocker blocker(spinbox); + spinbox->setValue(value); + }); + + QObject::connect(spinbox, + &QDoubleSpinBox::valueChanged, + spinbox, + [&attr, widget, min, max](double v) + { + attr.set_from_any( + std::clamp(static_cast(v), min, max)); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "Slider" || widget_type == "ScrollBar" || + widget_type == "Dial") + { + if (!attr.metadata().contains_all_keys( + {meta::keys::constraints::min, meta::keys::constraints::max})) + { + layout->addWidget(make_error_widget(&attr, "missing metadata", widget)); + return widget; + } + + constexpr int range_min = 0; + constexpr int range_max = 1000; + + auto to_int = [min, max](float v) -> int + { return static_cast(((v - min) / (max - min)) * range_max); }; + + auto from_int = [min, max](int v) -> float + { return min + (static_cast(v) / range_max) * (max - min); }; + + attr.set_from_any(std::clamp(value, min, max)); + + QAbstractSlider *control = nullptr; + + if (widget_type == "Slider") + { + auto *slider = new QSlider(Qt::Horizontal, widget); + slider->setRange(range_min, range_max); + slider->setValue(to_int(value)); + control = slider; + } + else if (widget_type == "ScrollBar") + { + auto *scrollbar = new QScrollBar(Qt::Horizontal, widget); + scrollbar->setRange(range_min, range_max); + scrollbar->setValue(to_int(value)); + control = scrollbar; + } + else if (widget_type == "Dial") + { + auto *dial = new QDial(widget); + dial->setRange(range_min, range_max); + dial->setValue(to_int(value)); + control = dial; + } + + widget->set_sync_from_model( + [control, &value, min, max]() + { + auto to_int = [min, max](float v) -> int + { return static_cast(((v - min) / (max - min)) * 1000); }; + + const QSignalBlocker blocker(control); + control->setValue(to_int(value)); + }); + + QObject::connect(control, + &QAbstractSlider::sliderPressed, + widget, + [widget]() { Q_EMIT widget->edit_started(); }); + + QObject::connect(control, + &QAbstractSlider::valueChanged, + widget, + [&attr, widget, from_int, min, max](int v) + { + attr.set_from_any(std::clamp(from_int(v), min, max)); + Q_EMIT widget->value_changed(); + }); + + QObject::connect(control, + &QAbstractSlider::sliderReleased, + widget, + [widget]() { Q_EMIT widget->edit_ended(); }); + + layout->addWidget(control); + } + else if (widget_type == "SliderFloat") + { + auto *slider = new SliderFloat(label_txt, + value, + min, + max, + plus_minus, + format, + log_scale, + widget); + slider->set_value(value); + layout->addWidget(slider); + + widget->set_sync_from_model( + [slider, &value]() + { + const QSignalBlocker blocker(slider); + slider->set_value(value); + }); + + QObject::connect(slider, + &SliderFloat::value_changed, + widget, + [&attr, slider, widget]() + { + attr.set_from_any(slider->get_value()); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + }); + + QObject::connect(slider, + &SliderFloat::edit_ended, + widget, + [&attr, slider, widget]() + { + attr.set_from_any(slider->get_value()); + Q_EMIT widget->edit_ended(); + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](float) { widget->sync_widget_from_model(); }); + + return widget; +} + +MetaWidget *render_int(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + const std::string format = meta::common::format(attr); + const int min = meta::common::min(attr); + const int max = meta::common::max(attr); + const int step = meta::common::step(attr); + const auto items = meta::common::enum_items(attr); + const bool plus_minus = meta::common::try_get(attr, + "ui.plus_minus", + false); + + int &value = attr.value(); + + MetaWidget *widget = make_meta_widget_vbox(parent); + auto *layout = static_cast(widget->layout()); + + if (!label_txt.empty() && widget_type != "SliderInt") + { + QLabel *label = new QLabel(label_txt.c_str(), widget); + layout->addWidget(label); + } + + if (widget_type.empty()) widget_type = "Input"; + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "Input") + { + auto *spinbox = new QDoubleSpinBox(widget); + spinbox->setMinimum(min); + spinbox->setMaximum(max); + spinbox->setSingleStep(step); + spinbox->setDecimals(0); + + const int clamped = std::clamp(value, min, max); + spinbox->setValue(clamped); + if (clamped != value) attr.set_from_any(clamped); + + layout->addWidget(spinbox); + + widget->set_sync_from_model( + [spinbox, &value]() + { + const QSignalBlocker blocker(spinbox); + spinbox->setValue(value); + }); + + QObject::connect(spinbox, + &QDoubleSpinBox::valueChanged, + spinbox, + [&attr, widget, min, max](double v) + { + const int iv = static_cast(std::lround(v)); + attr.set_from_any(std::clamp(iv, min, max)); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "EnumComboBox") + { + auto *combo = new QComboBox(widget); + layout->addWidget(combo); + + int current_index = 0; + int index = 0; + + for (const auto &[val, name] : items) + { + combo->addItem(QString::fromStdString(name), QVariant::fromValue(val)); + if (val == value) current_index = index; + ++index; + } + + combo->setCurrentIndex(current_index); + + widget->set_sync_from_model( + [combo, &value]() + { + const QSignalBlocker blocker(combo); + for (int i = 0; i < combo->count(); ++i) + { + if (combo->itemData(i).toInt() == value) + { + combo->setCurrentIndex(i); + break; + } + } + }); + + QObject::connect(combo, + QOverload::of(&QComboBox::currentIndexChanged), + widget, + [&attr, widget, combo](int) + { + attr.set_from_any(combo->currentData().toInt()); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "Slider" || widget_type == "ScrollBar" || + widget_type == "Dial") + { + if (!attr.metadata().contains_all_keys( + {meta::keys::constraints::min, meta::keys::constraints::max})) + { + layout->addWidget(make_error_widget(&attr, "missing metadata", widget)); + return widget; + } + + attr.set_from_any(std::clamp(value, min, max)); + + QAbstractSlider *control = nullptr; + + if (widget_type == "Slider") + { + auto *slider = new QSlider(Qt::Horizontal, widget); + slider->setRange(min, max); + slider->setValue(value); + control = slider; + } + else if (widget_type == "ScrollBar") + { + auto *scrollbar = new QScrollBar(Qt::Horizontal, widget); + scrollbar->setRange(min, max); + scrollbar->setValue(value); + control = scrollbar; + } + else if (widget_type == "Dial") + { + auto *dial = new QDial(widget); + dial->setRange(min, max); + dial->setValue(value); + control = dial; + } + + widget->set_sync_from_model( + [control, &value]() + { + const QSignalBlocker blocker(control); + control->setValue(value); + }); + + QObject::connect(control, + &QAbstractSlider::sliderPressed, + widget, + [widget]() { Q_EMIT widget->edit_started(); }); + + QObject::connect(control, + &QAbstractSlider::valueChanged, + widget, + [&attr, widget, min, max](int v) + { + attr.set_from_any(std::clamp(v, min, max)); + Q_EMIT widget->value_changed(); + }); + + QObject::connect(control, + &QAbstractSlider::sliderReleased, + widget, + [widget]() { Q_EMIT widget->edit_ended(); }); + + layout->addWidget(control); + } + else if (widget_type == "SliderInt") + { + auto *slider = + new SliderInt(label_txt, value, min, max, plus_minus, format, widget); + slider->set_value(value); + layout->addWidget(slider); + + widget->set_sync_from_model( + [slider, &value]() + { + const QSignalBlocker blocker(slider); + slider->set_value(value); + }); + + QObject::connect(slider, + &SliderInt::value_changed, + widget, + [&attr, slider, widget]() + { + attr.set_from_any(slider->get_value()); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + }); + + QObject::connect(slider, + &SliderInt::edit_ended, + widget, + [&attr, slider, widget]() + { + attr.set_from_any(slider->get_value()); + Q_EMIT widget->edit_ended(); + }); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](int) { widget->sync_widget_from_model(); }); + + return widget; +} + +} // namespace + +void register_stock_numeric(DesignRegistry ®istry) +{ + const std::type_index type_float = std::type_index(typeid(float)); + registry.add(kDesignName, type_float, "Input", render_float); + registry.add(kDesignName, type_float, "Slider", render_float); + registry.add(kDesignName, type_float, "ScrollBar", render_float); + registry.add(kDesignName, type_float, "Dial", render_float); + registry.add(kDesignName, type_float, "SliderFloat", render_float); + registry.add(kDesignName, type_float, kAnyWidgetType, render_float); + + const std::type_index type_int = std::type_index(typeid(int)); + registry.add(kDesignName, type_int, "Input", render_int); + registry.add(kDesignName, type_int, "Slider", render_int); + registry.add(kDesignName, type_int, "ScrollBar", render_int); + registry.add(kDesignName, type_int, "Dial", render_int); + registry.add(kDesignName, type_int, "SliderInt", render_int); + registry.add(kDesignName, type_int, "EnumComboBox", render_int); + registry.add(kDesignName, type_int, kAnyWidgetType, render_int); +} + +} // namespace meta::qt::stock diff --git a/MetaUI/qt/src/designs/stock/stock_string.cpp b/MetaUI/qt/src/designs/stock/stock_string.cpp new file mode 100644 index 0000000..8c23320 --- /dev/null +++ b/MetaUI/qt/src/designs/stock/stock_string.cpp @@ -0,0 +1,397 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "stock_internal.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "meta_common.hpp" +#include "meta_qt/designs/stock/stock.hpp" +#include "meta_qt/meta_widget.hpp" +#include "meta_qt/widgets/helpers.hpp" + +namespace meta::qt::stock +{ + +namespace +{ + +void apply_height_constraints(QPlainTextEdit *te, + Attribute &attr, + int default_min, + int default_max) +{ + const int min_lines = meta::common::try_get(attr, + "ui.min_lines", + default_min); + const int max_lines = meta::common::try_get(attr, + "ui.max_lines", + default_max); + + te->setMinimumHeight(meta::qt::helpers::plain_text_height(te, min_lines)); + te->setMaximumHeight(meta::qt::helpers::plain_text_height(te, max_lines)); +} + +std::pair make_apply_button(QWidget *parent) +{ + auto *btn_row = new QHBoxLayout(); + btn_row->setContentsMargins(0, 2, 0, 0); + btn_row->addStretch(); + + auto *apply_btn = new QPushButton(QObject::tr("Apply"), parent); + apply_btn->setFixedHeight(22); + apply_btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + apply_btn->setEnabled(false); + btn_row->addWidget(apply_btn); + + return {btn_row, apply_btn}; +} + +MetaWidget *render_string(AbstractAttribute &abstract_attr, + const RowContext &, + QWidget *parent) +{ + auto &attr = static_cast &>(abstract_attr); + std::string widget_type = meta::common::widget_type(attr); + const std::string label_txt = meta::common::label(attr); + const std::vector options = meta::common::allowed_values(attr); + std::string &value = attr.value(); + + if (widget_type.empty()) + widget_type = options.empty() ? "SingleLineText" : "ComboBox"; + + const bool needs_vbox = (widget_type == "MultilineText" || + widget_type == "CodeEditor"); + + MetaWidget *widget = needs_vbox ? make_meta_widget_vbox(parent) + : make_meta_widget_hbox(parent); + + auto *layout = static_cast(widget->layout()); + + if (!label_txt.empty()) + layout->addWidget(new QLabel(QString::fromStdString(label_txt), widget)); + + if (widget_type == "None") + { + return nullptr; + } + else if (widget_type == "ReadOnlyText") + { + auto *val_label = new QLabel(QString::fromStdString(value), widget); + val_label->setTextInteractionFlags(Qt::TextSelectableByMouse); + layout->addWidget(val_label); + + widget->set_sync_from_model( + [val_label, &value]() + { val_label->setText(QString::fromStdString(value)); }); + } + else if (widget_type == "SingleLineText") + { + const std::string placeholder = meta::common::try_get( + attr, + "ui.placeholder", + std::string{}); + const int max_length = meta::common::try_get(attr, "ui.max_length", 0); + + auto *line_edit = new QLineEdit(widget); + line_edit->setText(QString::fromStdString(value)); + + if (!placeholder.empty()) + line_edit->setPlaceholderText(QString::fromStdString(placeholder)); + + if (max_length > 0) line_edit->setMaxLength(max_length); + + auto *apply_btn = new QPushButton(QObject::tr("Apply"), widget); + apply_btn->setFixedHeight(22); + apply_btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + + auto *row = new QHBoxLayout(); + row->addWidget(line_edit); + row->addWidget(apply_btn); + layout->addLayout(row); + + widget->set_sync_from_model( + [line_edit, &value]() + { + const QSignalBlocker blocker(line_edit); + line_edit->setText(QString::fromStdString(value)); + }); + + auto do_apply = [&attr, line_edit, widget]() + { + attr.set_from_any(line_edit->text().toStdString()); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }; + + QObject::connect(apply_btn, &QPushButton::clicked, widget, do_apply); + QObject::connect(line_edit, &QLineEdit::returnPressed, widget, do_apply); + } + else if (widget_type == "MultilineText") + { + const std::string placeholder = meta::common::try_get( + attr, + "ui.placeholder", + std::string{}); + + auto *text_edit = new QPlainTextEdit(widget); + text_edit->setPlainText(QString::fromStdString(value)); + + if (!placeholder.empty()) + text_edit->setPlaceholderText(QString::fromStdString(placeholder)); + + apply_height_constraints(text_edit, attr, 4, 12); + layout->addWidget(text_edit); + + auto [btn_row, apply_btn] = make_apply_button(widget); + layout->addLayout(btn_row); + + widget->set_sync_from_model( + [text_edit, &value]() + { + const QSignalBlocker blocker(text_edit); + text_edit->setPlainText(QString::fromStdString(value)); + }); + + QObject::connect(apply_btn, + &QPushButton::clicked, + widget, + [&attr, text_edit, widget]() + { + attr.set_from_any( + text_edit->toPlainText().toStdString()); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "CodeEditor") + { + const std::string placeholder = meta::common::try_get( + attr, + "ui.placeholder", + std::string{}); + const int tab_width = meta::common::try_get(attr, "ui.tab_width", 4); + + auto *text_edit = new QPlainTextEdit(widget); + + QFont code_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); + for (const char *name : {"JetBrains Mono", + "Fira Code", + "Cascadia Code", + "Consolas", + "DejaVu Sans Mono", + "Courier New"}) + { + QFont f(name); + if (QFontInfo(f).fixedPitch()) + { + code_font = f; + break; + } + } + code_font.setPointSize(9); + text_edit->setFont(code_font); + + const int space_width = QFontMetrics(code_font).horizontalAdvance( + QLatin1Char(' ')); + text_edit->setTabStopDistance(tab_width * space_width); + text_edit->setLineWrapMode(QPlainTextEdit::NoWrap); + text_edit->setPlainText(QString::fromStdString(value)); + + if (!placeholder.empty()) + text_edit->setPlaceholderText(QString::fromStdString(placeholder)); + + apply_height_constraints(text_edit, attr, 6, 24); + layout->addWidget(text_edit); + + auto [btn_row, apply_btn] = make_apply_button(widget); + layout->addLayout(btn_row); + + widget->set_sync_from_model( + [text_edit, &value]() + { + const QSignalBlocker blocker(text_edit); + text_edit->setPlainText(QString::fromStdString(value)); + }); + + QObject::connect(apply_btn, + &QPushButton::clicked, + widget, + [&attr, text_edit, widget]() + { + attr.set_from_any( + text_edit->toPlainText().toStdString()); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "ComboBox") + { + auto *combo = new QComboBox(widget); + layout->addWidget(combo); + + int current_index = -1; + for (size_t i = 0; i < options.size(); ++i) + { + combo->addItem(QString::fromStdString(options[i])); + if (options[i] == value) current_index = static_cast(i); + } + if (current_index >= 0) combo->setCurrentIndex(current_index); + + widget->set_sync_from_model( + [&attr, combo, &value]() + { + const QSignalBlocker blocker(combo); + + const std::vector current_options = + meta::common::allowed_values(attr); + + bool items_differ = combo->count() != + static_cast(current_options.size()); + if (!items_differ) + { + for (int i = 0; i < combo->count(); ++i) + { + if (combo->itemText(i).toStdString() != current_options[i]) + { + items_differ = true; + break; + } + } + } + + if (items_differ) + { + combo->clear(); + for (const auto &opt : current_options) + combo->addItem(QString::fromStdString(opt)); + } + + const QString v = QString::fromStdString(value); + + for (int i = 0; i < combo->count(); ++i) + { + if (combo->itemText(i) == v) + { + combo->setCurrentIndex(i); + return; + } + } + }); + + QObject::connect(combo, + &QComboBox::currentTextChanged, + widget, + [&attr, widget](const QString &text) + { + attr.set_from_any(text.toStdString()); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + } + else if (widget_type == "ButtonGrid") + { + int max_cols = 5; + if (attr.metadata().contains("ui.columns")) + { + auto *c = attr.metadata().find("ui.columns"); + max_cols = std::any_cast(c->to_any()); + } + + const int n = static_cast(options.size()); + int ncols = std::min(max_cols, static_cast(std::ceil(std::sqrt(n)))); + + auto *grid = new QGridLayout(); + auto *group = new QButtonGroup(widget); + + bool exclusive = true; + if (attr.metadata().contains("ui.exclusive")) + { + auto *m2 = attr.metadata().find("ui.exclusive"); + exclusive = std::any_cast(m2->to_any()); + } + group->setExclusive(exclusive); + + for (int i = 0; i < n; ++i) + { + const std::string &choice = options[i]; + auto *btn = new QPushButton(QString::fromStdString(choice), widget); + btn->setCheckable(true); + btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + if (choice == value) btn->setChecked(true); + group->addButton(btn); + grid->addWidget(btn, i / ncols, i % ncols); + } + + widget->set_sync_from_model( + [group, &value]() + { + const QSignalBlocker blocker(group); + const QString v = QString::fromStdString(value); + + for (auto *button : group->buttons()) + { + if (button->text() == v) + { + button->setChecked(true); + return; + } + } + }); + + QObject::connect(group, + &QButtonGroup::buttonClicked, + widget, + [&attr, widget](QAbstractButton *button) + { + attr.set_from_any(button->text().toStdString()); + Q_EMIT widget->edit_started(); + Q_EMIT widget->value_changed(); + Q_EMIT widget->edit_ended(); + }); + + layout->addLayout(grid); + } + else + { + layout->addWidget( + make_error_widget(&attr, "unsupported widget type", widget)); + } + + widget->connection_ = attr.value_changed.subscribe( + [widget](const std::string &) { widget->sync_widget_from_model(); }); + + return widget; +} + +} // namespace + +void register_stock_string(DesignRegistry ®istry) +{ + const std::type_index type = std::type_index(typeid(std::string)); + registry.add(kDesignName, type, "ComboBox", render_string); + registry.add(kDesignName, type, "ButtonGrid", render_string); + registry.add(kDesignName, type, "SingleLineText", render_string); + registry.add(kDesignName, type, "MultilineText", render_string); + registry.add(kDesignName, type, "CodeEditor", render_string); + registry.add(kDesignName, type, "ReadOnlyText", render_string); + registry.add(kDesignName, type, kAnyWidgetType, render_string); +} + +} // namespace meta::qt::stock diff --git a/MetaUI/qt/src/ui/control.cpp b/MetaUI/qt/src/ui/control.cpp new file mode 100644 index 0000000..98ef1df --- /dev/null +++ b/MetaUI/qt/src/ui/control.cpp @@ -0,0 +1,84 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/ui/control.hpp" + +#include +#include + +namespace meta::qt +{ + +ControlBase::ControlBase(const RowContext &ctx, QWidget *parent) + : QWidget(parent), + ctx_(ctx), + theme_(ctx.theme ? ctx.theme : &ThemeRegistry::instance().fallback()) +{ + setFocusPolicy(Qt::StrongFocus); +} + +void ControlBase::set_locked(bool locked) +{ + if (locked_ == locked) return; + locked_ = locked; + on_state_changed(); +} + +void ControlBase::set_modified(bool modified) +{ + if (modified_ == modified) return; + modified_ = modified; + on_state_changed(); +} + +void ControlBase::begin_edit() +{ + if (editing_) return; + editing_ = true; + Q_EMIT edit_started(); +} + +void ControlBase::end_edit() +{ + if (!editing_) return; + editing_ = false; + Q_EMIT edit_ended(); +} + +void ControlBase::notify_value_changed() { Q_EMIT value_changed(); } + +QString ControlBase::elide_label(const QString &text, + const QFont &font, + int width) +{ + const QFontMetrics metrics(font); + const QString elided = metrics.elidedText(text, Qt::ElideRight, width); + + if (elided != text && toolTip().isEmpty()) + { + // The container sets ui.tooltip on the host row, not on the control, and it + // does so before the first paint -- so an empty parent tooltip here really + // does mean there is nothing to shadow. + QWidget *host = parentWidget(); + if (!host || host->toolTip().isEmpty()) setToolTip(text); + } + + return elided; +} + +void ControlBase::handle_wheel(QWheelEvent *event) { event->ignore(); } + +void ControlBase::wheelEvent(QWheelEvent *event) +{ + // Unfocused controls must let the wheel through to the scroll area, or + // scrolling the panel edits whatever value happens to be under the cursor. + if (!hasFocus() || locked_) + { + event->ignore(); + return; + } + + handle_wheel(event); +} + +} // namespace meta::qt diff --git a/MetaUI/qt/src/ui/design_registry.cpp b/MetaUI/qt/src/ui/design_registry.cpp new file mode 100644 index 0000000..4241af8 --- /dev/null +++ b/MetaUI/qt/src/ui/design_registry.cpp @@ -0,0 +1,198 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/ui/design_registry.hpp" + +#include + +#include "meta/logger.hpp" + +namespace meta::qt +{ + +DesignRegistry &DesignRegistry::instance() +{ + static DesignRegistry registry; + return registry; +} + +void DesignRegistry::add(const std::string &design, + std::type_index type, + const std::string &widget_type, + RowFactory factory) +{ + factories_[design][Key{type, widget_type}] = std::move(factory); +} + +void DesignRegistry::set_fallback(const std::string &design, + const std::string &fallback) +{ + fallbacks_[design] = fallback; +} + +MetaWidget *DesignRegistry::render(AbstractAttribute *p_attr, + const std::string &design, + const RowContext &ctx, + QWidget *parent) const +{ + if (!p_attr) return nullptr; + + // meta::common::widget_type() only has an Attribute overload; on an + // AbstractAttribute the key has to be read directly. + const std::string widget_type = meta::common::try_get( + *p_attr, + meta::keys::ui::widget_type, + ""); + + const std::type_index type{p_attr->type()}; + + std::set visited; + std::string current = design; + + while (!current.empty() && visited.insert(current).second) + { + auto design_it = factories_.find(current); + + if (design_it != factories_.end()) + { + const auto &table = design_it->second; + + // A factory may decline (nullptr) -- can_render() said no -- in which + // case the walk continues rather than stopping at a blank row. + if (auto it = table.find(Key{type, widget_type}); it != table.end()) + if (MetaWidget *row = it->second(*p_attr, ctx, parent)) return row; + + if (auto it = table.find(Key{type, kAnyWidgetType}); it != table.end()) + if (MetaWidget *row = it->second(*p_attr, ctx, parent)) return row; + } + + auto fallback_it = fallbacks_.find(current); + current = fallback_it == fallbacks_.end() ? std::string{} + : fallback_it->second; + } + + return nullptr; +} + +void DesignRegistry::register_section_factory(const std::string &design, + SectionFactory factory) +{ + section_factories_[design] = std::move(factory); +} + +SectionFactory DesignRegistry::section_factory(const std::string &design) const +{ + std::set visited; + std::string current = design; + + while (!current.empty() && visited.insert(current).second) + { + auto it = section_factories_.find(current); + if (it != section_factories_.end() && it->second) return it->second; + + auto fallback_it = fallbacks_.find(current); + current = fallback_it == fallbacks_.end() ? std::string{} + : fallback_it->second; + } + + // default fallback: standard stock collapsible section + return [](const QString &title) { return new CollapsibleSection(title); }; +} + +void DesignRegistry::set_theme(const std::string &design, + const std::string &theme_name) +{ + themes_[design] = theme_name; +} + +const Theme &DesignRegistry::theme(const std::string &design) const +{ + std::set visited; + std::string current = design; + + while (!current.empty() && visited.insert(current).second) + { + auto it = themes_.find(current); + if (it != themes_.end() && !it->second.empty()) + return ThemeRegistry::instance().get(it->second); + + auto fallback_it = fallbacks_.find(current); + current = fallback_it == fallbacks_.end() ? std::string{} + : fallback_it->second; + } + + return ThemeRegistry::instance().fallback(); +} + +bool DesignRegistry::has_control(const std::string &design, + std::type_index type, + const std::string &widget_type) const +{ + std::set visited; + std::string current = design; + + while (!current.empty() && visited.insert(current).second) + { + auto design_it = factories_.find(current); + if (design_it != factories_.end()) + { + const auto &table = design_it->second; + if (table.find(Key{type, widget_type}) != table.end() || + table.find(Key{type, kAnyWidgetType}) != table.end()) + return true; + } + + auto fallback_it = fallbacks_.find(current); + current = fallback_it == fallbacks_.end() ? std::string{} + : fallback_it->second; + } + + return false; +} + +bool DesignRegistry::has_design(const std::string &design) const +{ + return factories_.find(design) != factories_.end() || + section_factories_.find(design) != section_factories_.end(); +} + +std::vector DesignRegistry::designs() const +{ + std::vector out; + out.reserve(factories_.size() + section_factories_.size()); + for (const auto &[name, _] : factories_) + out.push_back(name); + for (const auto &[name, _] : section_factories_) + if (std::find(out.begin(), out.end(), name) == out.end()) + out.push_back(name); + return out; +} + +MetaWidget *render_row(AbstractAttribute *p_attr, + const std::string &design, + const RowContext &ctx, + QWidget *parent) +{ + if (!p_attr) + { + Logger::log()->error("render_row: incoming p_attr is nullptr"); + return nullptr; + } + + MetaWidget *row = DesignRegistry::instance().render(p_attr, + design, + ctx, + parent); + + if (!row) + Logger::log()->error("render_row: no factory in design '{}' (or its " + "fallbacks) for attribute " + "'{}' of type '{}'", + design, + p_attr->name(), + p_attr->type().name()); + + return row; +} + +} // namespace meta::qt diff --git a/MetaUI/qt/src/ui/glide.cpp b/MetaUI/qt/src/ui/glide.cpp new file mode 100644 index 0000000..af24e9f --- /dev/null +++ b/MetaUI/qt/src/ui/glide.cpp @@ -0,0 +1,61 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/ui/glide.hpp" + +#include + +namespace meta::qt +{ + +Glide::Glide(int duration_ms, QObject *parent) : QObject(parent) +{ + animation_.setDuration(duration_ms); + animation_.setEasingCurve(QEasingCurve::OutCubic); + + connect(&animation_, + &QVariantAnimation::valueChanged, + this, + [this](const QVariant &v) + { + current_ = v.toReal(); + Q_EMIT tick(current_); + }); + + connect(&animation_, + &QVariantAnimation::finished, + this, + [this]() { Q_EMIT finished(current_); }); +} + +void Glide::to(qreal target) +{ + if (qFuzzyCompare(target, current_) && !running()) + { + Q_EMIT finished(current_); + return; + } + + // A running animation ignores a retargeted end value -- stop before + // retargeting or this call silently does nothing. + animation_.stop(); + animation_.setStartValue(current_); + animation_.setEndValue(target); + animation_.start(); +} + +void Glide::jump(qreal value) +{ + animation_.stop(); + current_ = value; + Q_EMIT tick(current_); +} + +bool Glide::running() const +{ + return animation_.state() == QAbstractAnimation::Running; +} + +void Glide::set_duration(int ms) { animation_.setDuration(ms); } + +} // namespace meta::qt diff --git a/MetaUI/qt/src/ui/theme.cpp b/MetaUI/qt/src/ui/theme.cpp new file mode 100644 index 0000000..3ffaecc --- /dev/null +++ b/MetaUI/qt/src/ui/theme.cpp @@ -0,0 +1,267 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include "meta_qt/ui/theme.hpp" + +#include +#include + +namespace meta::qt +{ + +// --- fonts + +QFont mono_font(int pixel_size) +{ + static const QString family = []() -> QString + { + // Probe rather than name a family: the obvious choices are each present on + // exactly one platform, and a missing family falls back to a proportional + // face without warning. + const QStringList candidates = {"Consolas", + "DejaVu Sans Mono", + "Menlo", + "Liberation Mono", + "Noto Sans Mono", + "Courier New"}; + + const QStringList available = QFontDatabase::families(); + for (const QString &candidate : candidates) + if (available.contains(candidate)) return candidate; + + return QFontDatabase::systemFont(QFontDatabase::FixedFont).family(); + }(); + + QFont font(family); + font.setPixelSize(pixel_size); + font.setStyleHint(QFont::Monospace); + return font; +} + +QFont ui_font(int pixel_size, bool bold, qreal letter_spacing) +{ + // Probed rather than left to the default. Qt's fallback is whatever the + // platform hands out, which on some setups is a wide, soft face that reads + // nothing like the rest of the chrome. Every candidate here is a neutral + // grotesque; if none is installed we fall through to the platform default, + // which is the current behaviour rather than a regression. + static const QString family = []() -> QString + { + const QStringList candidates = {"Inter", + "Roboto", + "Segoe UI Variable Text", + "Segoe UI", + "Noto Sans", + "DejaVu Sans"}; + + const QStringList available = QFontDatabase::families(); + for (const QString &candidate : candidates) + if (available.contains(candidate)) return candidate; + + return QString(); + }(); + + QFont font; + if (!family.isEmpty()) font.setFamily(family); + font.setPixelSize(pixel_size); + font.setBold(bold); + if (letter_spacing != 0.0) + font.setLetterSpacing(QFont::AbsoluteSpacing, letter_spacing); + return font; +} + +// --- Theme + +namespace +{ + +/// Linear blend in RGB. `t` = 0 keeps `a`, `t` = 1 gives `b`. +QColor mix(const QColor &a, const QColor &b, qreal t) +{ + return QColor::fromRgbF(a.redF() * (1.0 - t) + b.redF() * t, + a.greenF() * (1.0 - t) + b.greenF() * t, + a.blueF() * (1.0 - t) + b.blueF() * t); +} + +/// Push a surface down (recessed). Same direction on light and dark schemes. +QColor sink(const QColor &c, qreal t) { return mix(c, QColor(0, 0, 0), t); } + +/// Push a surface up (raised). Same direction on light and dark schemes. +QColor lift(const QColor &c, qreal t) +{ + return mix(c, QColor(255, 255, 255), t); +} + +} // namespace + +Theme Theme::from_palette(const QPalette &palette, const std::string &name) +{ + Theme t; + t.name = name; + + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor base = palette.color(QPalette::Active, QPalette::Base); + const QColor text = palette.color(QPalette::Active, QPalette::Text); + const QColor mid = palette.color(QPalette::Active, QPalette::Mid); + const QColor light = palette.color(QPalette::Active, QPalette::Light); + + // --- surfaces + t.page = window; + t.bar = sink(window, 0.06); + // Deliberately a clear step off the page, not a hint of one. A card that is + // barely lighter than its background does not group anything. + t.section_surface = lift(window, 0.30); + + // The header shares the card surface so a section reads as one block rather + // than a bar with a differently coloured body under it. Hover and press are + // small steps off that, not a different colour. + t.section_header = t.section_surface; + t.section_header_hover = lift(t.section_surface, 0.06); + t.section_header_press = sink(t.section_surface, 0.04); + t.rail_well = sink(window, 0.35); + t.field = base; + t.field_hover = lift(base, 0.05); + t.field_editing = sink(base, 0.25); + t.switch_track_off = base; + + // --- hairlines and bevels + t.bevel_top = lift(window, 0.12); + t.bevel_bottom = sink(window, 0.10); + t.hairline = sink(window, 0.40); + t.rail_well_border = sink(window, 0.50); + t.field_border = mid; + t.field_border_hover = lift(mid, 0.15); + + // --- ink. Dimming blends towards the window, so it reads as "less + // prominent" whichever side of the light/dark line the scheme sits on. + t.ink_primary = text; + t.ink_section_title = mix(text, window, 0.12); + t.ink_secondary = mix(text, window, 0.38); + t.ink_dim = mix(text, window, 0.48); + t.ink_icon = mix(text, window, 0.20); + t.ink_locked = palette.color(QPalette::Disabled, QPalette::Text); + + // BrightText is the maximum-contrast ink, which is exactly what "modified" + // wants. Some palettes leave it equal to Text, in which case push away from + // the window instead so the modified state stays visibly distinct. + const QColor bright = palette.color(QPalette::Active, QPalette::BrightText); + t.ink_modified = bright == text + ? mix(text, + window.lightness() < 128 ? QColor(255, 255, 255) + : QColor(0, 0, 0), + 0.45) + : bright; + + // --- metal + t.thumb_top = light; + t.thumb_bottom = sink(light, 0.22); + t.thumb_border = sink(window, 0.55); + t.thumb_grip = mid; + t.knob_on_top = light; + t.knob_on_bottom = sink(light, 0.18); + t.knob_off_top = mid; + t.knob_off_bottom = sink(mid, 0.18); + + // --- accent. Follow the host rather than imposing one; assign over this + // afterwards to pin a specific accent. + t.accent = palette.color(QPalette::Active, QPalette::Highlight); + + return t; +} + +QColor Theme::group_accent(const std::string &category) const +{ + auto it = group_accents.find(category); + return it == group_accents.end() ? accent : it->second; +} + +QColor Theme::rail_fill(const std::string &category, bool locked) const +{ + QColor c = group_accent(category); + c.setAlphaF(locked ? locked_rail_fill_alpha : rail_fill_alpha); + return c; +} + +QColor Theme::switch_track_on() const +{ + return accent.darker(int(switch_track_on_darker * 100)); +} + +QColor Theme::state_ink(bool modified, bool locked) const +{ + if (locked) return ink_locked; + return modified ? ink_modified : ink_secondary; +} + +// --- ThemeRegistry + +namespace +{ + +/** @brief The reference colourway, sampled from a render rather than notes. + * + * Kept as an explicit option so the design can be seen exactly as it was + * drawn, independent of whatever palette the host happens to run. The *default* + * is the palette-derived theme, not this. + */ +Theme make_industrial_dark() +{ + Theme t; + t.name = "industrial-dark"; + return t; // the struct's member initialisers are industrial-dark +} + +} // namespace + +ThemeRegistry::ThemeRegistry() +{ + add(make_industrial_dark()); + fallback_name_ = kPaletteTheme; +} + +void ThemeRegistry::ensure_palette_theme() const +{ + if (themes_.find(kPaletteTheme) != themes_.end()) return; + + const QPalette palette = QApplication::palette(); + themes_[kPaletteTheme] = Theme::from_palette(palette, kPaletteTheme); +} + +ThemeRegistry &ThemeRegistry::instance() +{ + static ThemeRegistry registry; + return registry; +} + +void ThemeRegistry::add(Theme theme) +{ + const std::string key = theme.name; + themes_[key] = std::move(theme); +} + +const Theme &ThemeRegistry::get(const std::string &name) const +{ + ensure_palette_theme(); + + auto it = themes_.find(name); + return it == themes_.end() ? fallback() : it->second; +} + +const Theme &ThemeRegistry::fallback() const +{ + ensure_palette_theme(); + return themes_.at(fallback_name_); +} + +std::vector ThemeRegistry::names() const +{ + ensure_palette_theme(); + + std::vector out; + out.reserve(themes_.size()); + for (const auto &[name, _] : themes_) + out.push_back(name); + return out; +} + +} // namespace meta::qt diff --git a/MetaUI/qt/src/widget_renderer.cpp b/MetaUI/qt/src/widget_renderer.cpp index 526b2ab..b9560ef 100644 --- a/MetaUI/qt/src/widget_renderer.cpp +++ b/MetaUI/qt/src/widget_renderer.cpp @@ -2,7 +2,10 @@ Public License. The full license is in the file LICENSE, distributed with this software. */ #include "meta_qt/widget_renderer.hpp" + #include "meta/logger.hpp" +#include "meta_qt/designs/stock/stock.hpp" +#include "meta_qt/ui/design_registry.hpp" namespace meta::qt { @@ -15,94 +18,11 @@ MetaWidget *render(AbstractAttribute *p_attr, QWidget *parent) return nullptr; } - if (p_attr->type() == typeid(bool)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } - - if (p_attr->type() == typeid(float)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } - - if (p_attr->type() == typeid(int)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } - - if (p_attr->type() == typeid(std::string)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } - - if (p_attr->type() == typeid(std::filesystem::path)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } - - if (p_attr->type() == typeid(std::vector)) - { - auto &attr = static_cast> &>(*p_attr); - return WidgetRenderer>::render(attr, parent); - } - -#ifdef META_ENABLE_GLM_TYPES - if (p_attr->type() == typeid(glm::ivec2)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } - - if (p_attr->type() == typeid(glm::vec2)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } - - if (p_attr->type() == typeid(glm::vec3)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } - - if (p_attr->type() == typeid(glm::vec4)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } - - if (p_attr->type() == typeid(std::vector)) - { - auto &attr = static_cast> &>(*p_attr); - return WidgetRenderer>::render(attr, parent); - } -#endif - -#ifdef META_ENABLE_COLOR_GRADIENT_TYPES - if (p_attr->type() == typeid(meta::ColorGradient)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } -#endif - -#ifdef META_ENABLE_ARRAY_TYPES - if (p_attr->type() == typeid(meta::Array)) - { - auto &attr = static_cast &>(*p_attr); - return WidgetRenderer::render(attr, parent); - } -#endif - - Logger::log()->error("attribute type not supported '{}'", - p_attr->type().name()); - - return nullptr; + stock::register_design(); + return DesignRegistry::instance().render(p_attr, + stock::kDesignName, + RowContext{}, + parent); } } // namespace meta::qt diff --git a/MetaUI/qt/src/widgets/collapsible_section.cpp b/MetaUI/qt/src/widgets/collapsible_section.cpp index f0b81a9..07210c8 100644 --- a/MetaUI/qt/src/widgets/collapsible_section.cpp +++ b/MetaUI/qt/src/widgets/collapsible_section.cpp @@ -37,6 +37,8 @@ CollapsibleSection::CollapsibleSection(const QString &title, QWidget *parent) [this](bool checked) { this->set_expanded(checked); }); } +bool CollapsibleSection::is_expanded() const { return content->isVisible(); } + void CollapsibleSection::set_expanded(bool new_state) { content->setVisible(new_state); diff --git a/MetaUI/qt/src/widgets/gradient_picker.cpp b/MetaUI/qt/src/widgets/gradient_picker.cpp index 46053b1..c3ac508 100644 --- a/MetaUI/qt/src/widgets/gradient_picker.cpp +++ b/MetaUI/qt/src/widgets/gradient_picker.cpp @@ -145,7 +145,12 @@ void GradientBarWidget::mouseDoubleClickEvent(QMouseEvent *e) { stops_.push_back({float(pos), {1.f, 1.f, 1.f, 1.f}}); sort_stops(); - selected_idx_ = hit_test(e->pos()); + auto it = std::find_if(stops_.begin(), + stops_.end(), + [pos](const Stop &s) + { return s.position == float(pos); }); + if (it != stops_.end()) + selected_idx_ = static_cast(std::distance(stops_.begin(), it)); update(); Q_EMIT value_changed(); Q_EMIT edit_ended(); @@ -165,24 +170,33 @@ void GradientBarWidget::mousePressEvent(QMouseEvent *e) void GradientBarWidget::mouseMoveEvent(QMouseEvent *e) { - if (!dragging_ || selected_idx_ < 0) return; + if (!dragging_ || selected_idx_ < 0 || + selected_idx_ >= static_cast(stops_.size())) + return; const QRectF br = bar_rect(); - double pos = std::clamp((e->pos().x() - br.left()) / br.width(), 0.0, 1.0); + if (br.width() <= 0) return; - // Maintain minimum gap to avoid coincident positions. - constexpr double eps = 1e-3; - for (int i = 0; i < static_cast(stops_.size()); ++i) + const double pos = std::clamp((e->pos().x() - br.left()) / br.width(), + 0.0, + 1.0); + + stops_[selected_idx_].position = float(pos); + + // Maintain sorted order while keeping selected_idx_ tracking the moved stop + while (selected_idx_ > 0 && + stops_[selected_idx_].position < stops_[selected_idx_ - 1].position) { - if (i == selected_idx_) continue; - if (std::abs(double(stops_[i].position) - pos) < eps) - pos = pos < double(stops_[i].position) ? double(stops_[i].position) - eps - : double(stops_[i].position) + eps; - pos = std::clamp(pos, 0.0, 1.0); + std::swap(stops_[selected_idx_], stops_[selected_idx_ - 1]); + --selected_idx_; + } + while (selected_idx_ + 1 < static_cast(stops_.size()) && + stops_[selected_idx_].position > stops_[selected_idx_ + 1].position) + { + std::swap(stops_[selected_idx_], stops_[selected_idx_ + 1]); + ++selected_idx_; } - stops_[selected_idx_].position = float(pos); - sort_stops(); update(); Q_EMIT value_changed(); } @@ -237,7 +251,7 @@ QRectF GradientBarWidget::stop_rect(const Stop &s) const int GradientBarWidget::hit_test(const QPoint &pos) const { - for (int i = 0; i < static_cast(stops_.size()); ++i) + for (int i = static_cast(stops_.size()) - 1; i >= 0; --i) if (stop_rect(stops_[i]).adjusted(-2, -2, 2, 2).contains(QPointF(pos))) return i; return -1; diff --git a/MetaUI/qt/src/widgets/points_canvas.cpp b/MetaUI/qt/src/widgets/points_canvas.cpp index 453d718..0146eb7 100644 --- a/MetaUI/qt/src/widgets/points_canvas.cpp +++ b/MetaUI/qt/src/widgets/points_canvas.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -38,13 +39,23 @@ PointsCanvas::PointsCanvas(std::vector &points, mode_(mode), closed_(closed) { - setMinimumSize(200, 180); + setMinimumSize(200, 200); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - setFixedHeight(220); setMouseTracking(true); setCursor(Qt::CrossCursor); } +void PointsCanvas::resizeEvent(QResizeEvent *event) +{ + // Only react to a width change. Recomputing height inside the layout pass + // that just resized us re-invalidates it, turning one resize into several + // full layout passes. + if (event->oldSize().width() != event->size().width()) + setFixedHeight(width()); + + QWidget::resizeEvent(event); +} + QRect PointsCanvas::canvas_rect() const { return rect().adjusted(PAD, PAD, -PAD, -PAD); diff --git a/tests/test_qt/test_meta_qt/main.cpp b/tests/test_qt/test_meta_qt/main.cpp index 0448626..d5320d7 100644 --- a/tests/test_qt/test_meta_qt/main.cpp +++ b/tests/test_qt/test_meta_qt/main.cpp @@ -18,7 +18,10 @@ #include #include "meta.hpp" +#include "meta/core/data_provider.hpp" #include "meta_qt.hpp" +#include "meta_qt/designs/industrial/industrial.hpp" +#include "meta_qt/designs/stock/stock.hpp" #include "meta_qt/widgets/points_canvas.hpp" #include "meta_qt/widgets/range_bar.hpp" @@ -148,7 +151,8 @@ QWidget *make_debug_view(meta::AbstractAttribute *p_attr, // ----------------------------------------------------------------------------- QWidget *make_container_view(meta::AttributeContainer &container, - bool snapshots = false) + bool snapshots = false, + const std::string &design = "industrial") { auto *scroll = new QScrollArea(); scroll->setWidgetResizable(true); @@ -159,6 +163,7 @@ QWidget *make_container_view(meta::AttributeContainer &container, layout->setContentsMargins(4, 4, 4, 4); meta::qt::ContainerRenderOptions options; + options.design = design; options.category_policy = meta::qt::CategoryPolicy::CP_MERGED; options.snapshot_manager = snapshots; @@ -186,7 +191,8 @@ QWidget *make_container_view(meta::AttributeContainer &container, return scroll; } -QWidget *make_group_view(meta::ContainerGroup &group) +QWidget *make_group_view(meta::ContainerGroup &group, + const std::string &design = "industrial") { auto *scroll = new QScrollArea(); scroll->setWidgetResizable(true); @@ -197,6 +203,7 @@ QWidget *make_group_view(meta::ContainerGroup &group) layout->setContentsMargins(4, 4, 4, 4); meta::qt::ContainerRenderOptions options; + options.design = design; options.category_policy = meta::qt::CategoryPolicy::CP_TREE; options.collapse_regex = std::regex("^Cat 1"); options.snapshot_manager = true; @@ -776,9 +783,7 @@ void add_array_tests(meta::AttributeContainer &container) void add_group_tests(meta::ContainerGroup &group) { auto &node_settings = group.add("node_settings"); - auto &ui_settings = group.add("ui_settings"); - auto &debug_settings = group.add("debug_settings"); group.set_current("node_settings"); @@ -791,11 +796,8 @@ void add_group_tests(meta::ContainerGroup &group) auto *a = node_settings.add("threshold", 0.5f); a->metadata().add(meta::keys::constraints::min, 0.f); - a->metadata().add(meta::keys::constraints::max, 5.f); - a->metadata().add(meta::keys::ui::widget_type, "Slider"); - a->metadata().add(meta::keys::ui::category, "Base/Something/Category 2"); } @@ -812,9 +814,7 @@ void add_group_tests(meta::ContainerGroup &group) // --------------------------------------------------------------------------- ui_settings.add("theme", std::string("dark")); - ui_settings.add("font_size", 14.f); - ui_settings.add("show_grid", true); // --------------------------------------------------------------------------- @@ -822,18 +822,18 @@ void add_group_tests(meta::ContainerGroup &group) // --------------------------------------------------------------------------- debug_settings.add("log_level", 2); - debug_settings.add("wireframe", false); - debug_settings.add("draw_bounds", true); + debug_settings.add("show_grid", true); // --------------------------------------------------------------------------- // Presets // --------------------------------------------------------------------------- meta::presets::seed(node_settings, "seed", "Random Seed"); - meta::presets::angle(node_settings, "angle", "Angle"); + + group.synchronize_all(); } // ----------------------------------------------------------------------------- @@ -953,6 +953,8 @@ int main(int argc, char *argv[]) QApplication app(argc, argv); + meta::qt::industrial::register_design(); + auto *tabs = new QTabWidget(); tabs->setDocumentMode(true); @@ -963,48 +965,64 @@ int main(int argc, char *argv[]) if (base_bool) { - tabs->addTab(make_container_view(bool_container), "Bool"); + tabs->addTab(make_container_view(bool_container, false, "industrial"), + "Bool (Industrial)"); + tabs->addTab(make_container_view(bool_container, false, "stock"), + "Bool (Stock)"); } if (base_float) { - tabs->addTab(make_container_view(float_container), "Float"); + tabs->addTab(make_container_view(float_container, false, "industrial"), + "Float (Industrial)"); + tabs->addTab(make_container_view(float_container, false, "stock"), + "Float (Stock)"); } if (base_int) { - tabs->addTab(make_container_view(int_container), "Int"); + tabs->addTab(make_container_view(int_container, false, "industrial"), + "Int (Industrial)"); + tabs->addTab(make_container_view(int_container, false, "stock"), + "Int (Stock)"); } if (base_string) { - tabs->addTab(make_container_view(string_container), "String"); + tabs->addTab(make_container_view(string_container, false, "industrial"), + "String (Industrial)"); + tabs->addTab(make_container_view(string_container, false, "stock"), + "String (Stock)"); } if (base_std) { - tabs->addTab(make_container_view(std_container), "std"); + tabs->addTab(make_container_view(std_container, false, "industrial"), + "std"); } #ifdef META_ENABLE_GLM_TYPES if (base_glm) { - tabs->addTab(make_container_view(glm_container), "GLM"); + tabs->addTab(make_container_view(glm_container, false, "industrial"), + "GLM"); } #endif #ifdef META_ENABLE_COLOR_GRADIENT_TYPES if (base_color_gradient) { - tabs->addTab(make_container_view(color_gradient_container), - "Color Gradient"); + tabs->addTab( + make_container_view(color_gradient_container, false, "industrial"), + "Color Gradient"); } #endif #ifdef META_ENABLE_ARRAY_TYPES if (base_array) { - tabs->addTab(make_container_view(array_container), "Array"); + tabs->addTab(make_container_view(array_container, false, "industrial"), + "Array"); } #endif @@ -1014,7 +1032,8 @@ int main(int argc, char *argv[]) if (base_groups) { - tabs->addTab(make_group_view(group), "ContainerGroup"); + tabs->addTab(make_group_view(group, "industrial"), "Group (Industrial)"); + tabs->addTab(make_group_view(group, "stock"), "Group (Stock)"); } // --------------------------------------------------------------------------- @@ -1059,18 +1078,25 @@ int main(int argc, char *argv[]) float_container.snapshot_manager().save("Some Config.", float_container.json_to()); - tabs->addTab(make_container_view(float_container, true), + tabs->addTab(make_container_view(float_container, true, "industrial"), "Float + Snapshots"); } // --------------------------------------------------------------------------- - // Multiple render test + // Multiple render test (Industrial vs Stock) // --------------------------------------------------------------------------- if (true) { - auto *widget1 = meta::qt::render(float_container); - auto *widget2 = meta::qt::render(float_container); + meta::qt::ContainerRenderOptions opt_ind; + opt_ind.design = "industrial"; + auto *widget1 = meta::qt::render(float_container, opt_ind); + widget1->setWindowTitle("Float Container - Industrial"); + + meta::qt::ContainerRenderOptions opt_stock; + opt_stock.design = "stock"; + auto *widget2 = meta::qt::render(float_container, opt_stock); + widget2->setWindowTitle("Float Container - Stock"); widget1->show(); widget2->show(); diff --git a/tests/unittests/CMakeLists.txt b/tests/unittests/CMakeLists.txt index 4ebdc36..79d94c0 100644 --- a/tests/unittests/CMakeLists.txt +++ b/tests/unittests/CMakeLists.txt @@ -16,19 +16,40 @@ endif() enable_testing() -add_executable(meta_unittests +set(UNITTEST_SOURCES main.cpp test_attribute.cpp test_attribute_container.cpp test_serialization.cpp test_attribute_factory.cpp test_event.cpp + test_container_group_sync.cpp +) + +set(UNITTEST_LIBS + meta + GTest::gtest +) + +if(META_ENABLE_QT_UI) + set(CMAKE_AUTOMOC ON) + list(APPEND UNITTEST_SOURCES test_design_registry.cpp) + list(APPEND UNITTEST_LIBS meta_qt) + find_package(Qt6 REQUIRED COMPONENTS Core Widgets) + list(APPEND UNITTEST_LIBS Qt6::Core Qt6::Widgets) +endif() + +add_executable(meta_unittests + ${UNITTEST_SOURCES} ) +if(META_ENABLE_QT_UI) + target_compile_definitions(meta_unittests PRIVATE META_ENABLE_QT_UI) +endif() + target_link_libraries(meta_unittests PRIVATE - meta - GTest::gtest + ${UNITTEST_LIBS} ) include(GoogleTest) diff --git a/tests/unittests/main.cpp b/tests/unittests/main.cpp index d673902..0aa513b 100644 --- a/tests/unittests/main.cpp +++ b/tests/unittests/main.cpp @@ -1,9 +1,20 @@ #include "meta.hpp" #include +#ifdef META_ENABLE_QT_UI +#include +#endif + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); meta::Logger::log()->set_level(spdlog::level::warn); + +#ifdef META_ENABLE_QT_UI + int qt_argc = 0; + char **qt_argv = nullptr; + QApplication app(qt_argc, qt_argv); +#endif + return RUN_ALL_TESTS(); } diff --git a/tests/unittests/test_container_group_sync.cpp b/tests/unittests/test_container_group_sync.cpp new file mode 100644 index 0000000..67790e9 --- /dev/null +++ b/tests/unittests/test_container_group_sync.cpp @@ -0,0 +1,307 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include + +#include "meta/core/container_group.hpp" + +TEST(ContainerGroupSyncTest, DetectSharedAttributes) +{ + meta::ContainerGroup group; + + auto &c1 = group.add("Feature1"); + c1.add("scale", 1.0f); + c1.add("intensity", 0.5f); + c1.add("specific1", 10); + c1.add("tag", std::string("alpha")); + + auto &c2 = group.add("Feature2"); + c2.add("scale", 2.0f); + c2.add("intensity", 0.8f); + c2.add("specific2", 20); + c2.add("tag", std::string("beta")); + + auto &c3 = group.add("Feature3"); + c3.add("scale", 3.0f); + c3.add("specific3", 30); + c3.add("tag", 123); // different type (int vs string) + + // scale is in c1, c2, c3 (float) -> shared + // intensity is in c1, c2 (float) -> shared + // tag is in c1, c2 (string) -> shared (between c1 and c2) + // specific1, specific2, specific3 -> not shared + + EXPECT_TRUE(group.is_shared("scale")); + EXPECT_TRUE(group.is_shared("intensity")); + EXPECT_TRUE(group.is_shared("tag")); + EXPECT_FALSE(group.is_shared("specific1")); + EXPECT_FALSE(group.is_shared("specific2")); + EXPECT_FALSE(group.is_shared("specific3")); + EXPECT_FALSE(group.is_shared("nonexistent")); + + auto shared = group.shared_attributes(); + EXPECT_EQ(shared.size(), 3u); + EXPECT_EQ(shared[0], "scale"); + EXPECT_EQ(shared[1], "intensity"); + EXPECT_EQ(shared[2], "tag"); +} + +TEST(ContainerGroupSyncTest, BasicSynchronizationAndIndependence) +{ + meta::ContainerGroup group; + + auto &c1 = group.add("Feature1"); + c1.add("scale", 1.0f); + c1.add("intensity", 0.5f); + c1.add("offset", 10); + + auto &c2 = group.add("Feature2"); + c2.add("scale", 2.0f); + c2.add("intensity", 0.8f); + c2.add("offset", 20); + + // Mark only "scale" as synchronized + group.set_synchronized("scale", true); + EXPECT_TRUE(group.is_synchronized("scale")); + EXPECT_FALSE(group.is_synchronized("intensity")); + EXPECT_FALSE(group.is_synchronized("offset")); + + // Initial sync should propagate current_ (Feature1) value (1.0f) to Feature2 + EXPECT_FLOAT_EQ(c1.value("scale"), 1.0f); + EXPECT_FLOAT_EQ(c2.value("scale"), 1.0f); + + // Non-synchronized attributes should remain independent + EXPECT_FLOAT_EQ(c1.value("intensity"), 0.5f); + EXPECT_FLOAT_EQ(c2.value("intensity"), 0.8f); + EXPECT_EQ(c1.value("offset"), 10); + EXPECT_EQ(c2.value("offset"), 20); + + // Update scale on c1 via set_value -> c2 should update + c1.find("scale")->try_cast>()->set_value(5.0f); + EXPECT_FLOAT_EQ(c1.value("scale"), 5.0f); + EXPECT_FLOAT_EQ(c2.value("scale"), 5.0f); + + // Update scale on c2 via set_from_any -> c1 should update + c2.find("scale")->set_from_any(std::make_any(7.5f)); + EXPECT_FLOAT_EQ(c1.value("scale"), 7.5f); + EXPECT_FLOAT_EQ(c2.value("scale"), 7.5f); + + // Changing non-synchronized attribute on c1 does not affect c2 + c1.find("intensity")->try_cast>()->set_value(0.1f); + EXPECT_FLOAT_EQ(c1.value("intensity"), 0.1f); + EXPECT_FLOAT_EQ(c2.value("intensity"), 0.8f); +} + +TEST(ContainerGroupSyncTest, EventNotificationOnSync) +{ + meta::ContainerGroup group; + + auto &c1 = group.add("Feature1"); + c1.add("scale", 1.0f); + + auto &c2 = group.add("Feature2"); + c2.add("scale", 2.0f); + + group.set_synchronized("scale", true); + + float c2_notified_value = 0.0f; + int c2_notification_count = 0; + + auto *c2_attr = c2.find("scale")->try_cast>(); + ASSERT_NE(c2_attr, nullptr); + + auto conn = c2_attr->value_changed.subscribe( + [&](float val) + { + c2_notified_value = val; + c2_notification_count++; + }); + + // Update c1 + c1.find("scale")->try_cast>()->set_value(9.0f); + + EXPECT_FLOAT_EQ(c1.value("scale"), 9.0f); + EXPECT_FLOAT_EQ(c2.value("scale"), 9.0f); + EXPECT_EQ(c2_notification_count, 1); + EXPECT_FLOAT_EQ(c2_notified_value, 9.0f); +} + +TEST(ContainerGroupSyncTest, EnableDisableSynchronization) +{ + meta::ContainerGroup group; + + auto &c1 = group.add("Feature1"); + c1.add("scale", 1.0f); + + auto &c2 = group.add("Feature2"); + c2.add("scale", 2.0f); + + group.set_synchronized("scale", true); + EXPECT_TRUE(group.is_synchronized("scale")); + EXPECT_FLOAT_EQ(c2.value("scale"), 1.0f); + + // Disable synchronization + group.set_synchronized("scale", false); + EXPECT_FALSE(group.is_synchronized("scale")); + + // Updating c1 should not affect c2 anymore + c1.find("scale")->try_cast>()->set_value(10.0f); + EXPECT_FLOAT_EQ(c1.value("scale"), 10.0f); + EXPECT_FLOAT_EQ(c2.value("scale"), 1.0f); + + // Updating c2 should not affect c1 + c2.find("scale")->try_cast>()->set_value(20.0f); + EXPECT_FLOAT_EQ(c1.value("scale"), 10.0f); + EXPECT_FLOAT_EQ(c2.value("scale"), 20.0f); +} + +TEST(ContainerGroupSyncTest, SynchronizeAllAndClear) +{ + meta::ContainerGroup group; + + auto &c1 = group.add("Feature1"); + c1.add("a", 1.0f); + c1.add("b", 2.0f); + c1.add("c", 3); + + auto &c2 = group.add("Feature2"); + c2.add("a", 10.0f); + c2.add("b", 20.0f); + c2.add("c", 30); + + group.synchronize_all(true); + EXPECT_TRUE(group.is_synchronized("a")); + EXPECT_TRUE(group.is_synchronized("b")); + EXPECT_TRUE(group.is_synchronized("c")); + + EXPECT_FLOAT_EQ(c2.value("a"), 1.0f); + EXPECT_FLOAT_EQ(c2.value("b"), 2.0f); + EXPECT_EQ(c2.value("c"), 3); + + group.clear_synchronizations(); + EXPECT_FALSE(group.is_synchronized("a")); + EXPECT_FALSE(group.is_synchronized("b")); + EXPECT_FALSE(group.is_synchronized("c")); + EXPECT_TRUE(group.synchronized_attributes().empty()); +} + +TEST(ContainerGroupSyncTest, DynamicAdditionOfContainersAndAttributes) +{ + meta::ContainerGroup group; + + auto &c1 = group.add("Feature1"); + c1.add("scale", 1.0f); + + auto &c2 = group.add("Feature2"); + c2.add("scale", 2.0f); + + group.set_synchronized("scale", true); + EXPECT_FLOAT_EQ(c2.value("scale"), 1.0f); + + // Add a new container dynamically + auto &c3 = group.add("Feature3"); + // Add synchronized attribute to c3 + c3.add("scale", 99.0f); + + // c3 should automatically receive the synchronized value + EXPECT_FLOAT_EQ(c3.value("scale"), 1.0f); + + // Updating c3 should update c1 and c2 + c3.find("scale")->try_cast>()->set_value(42.0f); + EXPECT_FLOAT_EQ(c1.value("scale"), 42.0f); + EXPECT_FLOAT_EQ(c2.value("scale"), 42.0f); + EXPECT_FLOAT_EQ(c3.value("scale"), 42.0f); +} + +TEST(ContainerGroupSyncTest, EraseContainerMaintainsSync) +{ + meta::ContainerGroup group; + + auto &c1 = group.add("Feature1"); + c1.add("scale", 1.0f); + + auto &c2 = group.add("Feature2"); + c2.add("scale", 2.0f); + + auto &c3 = group.add("Feature3"); + c3.add("scale", 3.0f); + + group.set_synchronized("scale", true); + + // Erase c2 + EXPECT_TRUE(group.erase("Feature2")); + EXPECT_EQ(group.size(), 2u); + + // c1 and c3 continue to sync + c1.find("scale")->try_cast>()->set_value(15.0f); + EXPECT_FLOAT_EQ(c1.value("scale"), 15.0f); + EXPECT_FLOAT_EQ(c3.value("scale"), 15.0f); +} + +TEST(ContainerGroupSyncTest, JsonRoundTripPreservesSync) +{ + meta::ContainerGroup src; + + auto &c1 = src.add("Feature1"); + c1.add("scale", 1.0f); + c1.add("name", std::string("Default")); + + auto &c2 = src.add("Feature2"); + c2.add("scale", 2.0f); + c2.add("name", std::string("Custom")); + + src.set_synchronized("scale", true); + + nlohmann::json j = src.json_to(meta::SerializationMode::full); + EXPECT_TRUE(j.contains("synchronized_attributes")); + + meta::ContainerGroup dst; + dst.json_from(j, meta::SerializationMode::full); + + EXPECT_TRUE(dst.is_synchronized("scale")); + EXPECT_FALSE(dst.is_synchronized("name")); + + auto *dst_c1 = dst.find("Feature1"); + auto *dst_c2 = dst.find("Feature2"); + ASSERT_NE(dst_c1, nullptr); + ASSERT_NE(dst_c2, nullptr); + + EXPECT_FLOAT_EQ(dst_c1->value("scale"), 1.0f); + EXPECT_FLOAT_EQ(dst_c2->value("scale"), 1.0f); + + // Verify synchronization is active in deserialized group + dst_c1->find("scale")->try_cast>()->set_value(88.0f); + EXPECT_FLOAT_EQ(dst_c1->value("scale"), 88.0f); + EXPECT_FLOAT_EQ(dst_c2->value("scale"), 88.0f); +} + +TEST(ContainerGroupSyncTest, SetCurrentToFirst) +{ + meta::ContainerGroup group; + + // Expect exception when group is empty + EXPECT_THROW(group.set_current_to_first(), std::runtime_error); + + group.add("Feature1"); + group.add("Feature2"); + group.add("Feature3"); + + // Initial current is Feature1 + EXPECT_EQ(group.current_container_name(), "Feature1"); + + // Switch to Feature3 + group.set_current("Feature3"); + EXPECT_EQ(group.current_container_name(), "Feature3"); + + // Reset to first + group.set_current_to_first(); + EXPECT_EQ(group.current_container_name(), "Feature1"); + + // Erase the first one, now Feature2 is first + group.erase("Feature1"); + group.set_current("Feature3"); + EXPECT_EQ(group.current_container_name(), "Feature3"); + + group.set_current_to_first(); + EXPECT_EQ(group.current_container_name(), "Feature2"); +} diff --git a/tests/unittests/test_design_registry.cpp b/tests/unittests/test_design_registry.cpp new file mode 100644 index 0000000..dea60a3 --- /dev/null +++ b/tests/unittests/test_design_registry.cpp @@ -0,0 +1,209 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#include + +#include "meta/core/attribute.hpp" +#include "meta/core/attribute_container.hpp" +#include "meta_common.hpp" +#include "meta_qt/container_widget.hpp" +#include "meta_qt/designs/industrial/industrial.hpp" +#include "meta_qt/designs/stock/stock.hpp" +#include "meta_qt/ui/control.hpp" +#include "meta_qt/ui/design_registry.hpp" +#include "meta_qt/ui/theme.hpp" + +namespace +{ + +// Dummy test control that always accepts +class DummyAcceptControl : public meta::qt::Control +{ +public: + DummyAcceptControl(meta::Attribute &attr, + const meta::qt::RowContext &ctx, + QWidget *parent = nullptr) + : meta::qt::Control(ctx, parent), value_(attr.value()) + { + } + + static bool can_render(const meta::Attribute &) { return true; } + + float get() const override { return value_; } + void set(const float &val) override { value_ = val; } + +private: + float value_ = 0.f; +}; + +// Dummy test control that always rejects +class DummyRejectControl : public meta::qt::Control +{ +public: + DummyRejectControl(meta::Attribute &attr, + const meta::qt::RowContext &ctx, + QWidget *parent = nullptr) + : meta::qt::Control(ctx, parent), value_(attr.value()) + { + } + + static bool can_render(const meta::Attribute &) { return false; } + + float get() const override { return value_; } + void set(const float &val) override { value_ = val; } + +private: + float value_ = 0.f; +}; + +} // namespace + +TEST(DesignRegistryTest, RegistrationAndLookup) +{ + // --- Test basic registration and lookup + + auto ®istry = meta::qt::DesignRegistry::instance(); + registry.register_control("test_design", + "CustomFloat"); + + EXPECT_TRUE(registry.has_design("test_design")); + EXPECT_TRUE(registry.has_control("test_design", + std::type_index(typeid(float)), + "CustomFloat")); + EXPECT_FALSE(registry.has_control("test_design", + std::type_index(typeid(int)), + "CustomFloat")); +} + +TEST(DesignRegistryTest, FallbackChainAndWildcards) +{ + // --- Test fallback resolution and wildcard handling + + auto ®istry = meta::qt::DesignRegistry::instance(); + + meta::qt::stock::register_design(); + meta::qt::industrial::register_design(); + + // "test_sub" -> "industrial" -> "stock" + registry.set_fallback("test_sub", "industrial"); + + meta::Attribute attr("test_attr", 1.5f); + attr.metadata().add(meta::keys::ui::widget_type, "SliderFloat"); + attr.metadata().add(meta::keys::constraints::min, 0.f); + attr.metadata().add(meta::keys::constraints::max, 10.f); + + meta::qt::RowContext ctx; + auto *widget = registry.render(&attr, "test_sub", ctx); + ASSERT_NE(widget, nullptr); + delete widget; +} + +TEST(DesignRegistryTest, CanRenderRejectionFallback) +{ + // --- Test that can_render() returning false falls through to fallback design + + auto ®istry = meta::qt::DesignRegistry::instance(); + + meta::qt::stock::register_design(); + + registry.register_control("rejecting_design", + "SliderFloat"); + registry.set_fallback("rejecting_design", "stock"); + + meta::Attribute attr("float_attr", 2.0f); + attr.metadata().add(meta::keys::ui::widget_type, "SliderFloat"); + attr.metadata().add(meta::keys::constraints::min, 0.f); + attr.metadata().add(meta::keys::constraints::max, 10.f); + + meta::qt::RowContext ctx; + auto *widget = registry.render(&attr, "rejecting_design", ctx); + ASSERT_NE(widget, nullptr); + delete widget; +} + +TEST(DesignRegistryTest, CycleDetectionInFallbacks) +{ + // --- Test cycle safety in fallback chains + + auto ®istry = meta::qt::DesignRegistry::instance(); + + registry.set_fallback("cycle_a", "cycle_b"); + registry.set_fallback("cycle_b", "cycle_a"); + + meta::Attribute attr("unsupported", 0.0); + meta::qt::RowContext ctx; + + // Should return nullptr gracefully without looping + auto *widget = registry.render(&attr, "cycle_a", ctx); + EXPECT_EQ(widget, nullptr); +} + +TEST(DesignRegistryTest, SectionFactoryResolution) +{ + // --- Test SectionFactory registration and resolution + + auto ®istry = meta::qt::DesignRegistry::instance(); + + meta::qt::stock::register_design(); + meta::qt::industrial::register_design(); + + auto stock_sec_factory = registry.section_factory("stock"); + ASSERT_TRUE(stock_sec_factory); + auto *stock_sec = stock_sec_factory("Stock Section"); + ASSERT_NE(stock_sec, nullptr); + delete stock_sec; + + auto industrial_sec_factory = registry.section_factory("industrial"); + ASSERT_TRUE(industrial_sec_factory); + auto *industrial_sec = industrial_sec_factory("Industrial Section"); + ASSERT_NE(industrial_sec, nullptr); + delete industrial_sec; + + // Fallback section resolution + registry.set_fallback("custom_skin", "industrial"); + auto fallback_sec_factory = registry.section_factory("custom_skin"); + ASSERT_TRUE(fallback_sec_factory); + auto *fallback_sec = fallback_sec_factory("Custom Skin Section"); + ASSERT_NE(fallback_sec, nullptr); + delete fallback_sec; +} + +TEST(DesignRegistryTest, ContainerWidgetDesignIntegration) +{ + // --- Test rendering full AttributeContainer using + // ContainerRenderOptions::design + + meta::qt::stock::register_design(); + meta::qt::industrial::register_design(); + + meta::AttributeContainer container; + auto *a1 = container.add("param_float", 0.5f); + a1->metadata().add(meta::keys::ui::widget_type, "SliderFloat"); + a1->metadata().add(meta::keys::constraints::min, 0.f); + a1->metadata().add(meta::keys::constraints::max, 1.f); + a1->metadata().add(meta::keys::ui::category, "Parameters"); + + auto *a2 = container.add("param_bool", true); + a2->metadata().add(meta::keys::ui::widget_type, "Toggle"); + a2->metadata().add(meta::keys::ui::category, "Parameters"); + + // Render with stock design + { + meta::qt::ContainerRenderOptions options; + options.design = "stock"; + options.category_policy = meta::qt::CategoryPolicy::CP_MERGED; + auto *widget = meta::qt::render(container, options); + ASSERT_NE(widget, nullptr); + delete widget; + } + + // Render with industrial design + { + meta::qt::ContainerRenderOptions options; + options.design = "industrial"; + options.category_policy = meta::qt::CategoryPolicy::CP_MERGED; + auto *widget = meta::qt::render(container, options); + ASSERT_NE(widget, nullptr); + delete widget; + } +}