From 69a02089cac9835ab3c6a7a84aa3cd65a0bfa61b Mon Sep 17 00:00:00 2001 From: Otto Link <121820229+otto-link@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:53:35 +0200 Subject: [PATCH 01/14] Add permissions for GitHub Actions workflow --- .github/workflows/deploy_docs.yml | 5 +++++ 1 file changed, 5 insertions(+) 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 From 71d2e700b71f48e2e5b0cdbf2195059627e9b815 Mon Sep 17 00:00:00 2001 From: Otto Link <121820229+otto-link@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:54:00 +0200 Subject: [PATCH 02/14] Add CODEOWNERS file for otto-link --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS 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 From c9c2ba0ad2b43066eb7540c164819ccf59721107 Mon Sep 17 00:00:00 2001 From: Otto Link Date: Thu, 27 Aug 2026 10:22:51 +0200 Subject: [PATCH 03/14] feat: add option to synchronize shared attributes across containers in a ContainerGroup (#40) - Add value_changed_event to AbstractAttribute for untyped notification - Add attribute_added and attribute_removed events to AttributeContainer - Add shared attribute detection (shared_attributes, is_shared) to ContainerGroup - Add synchronization controls (set_synchronized, is_synchronized, synchronize_all, clear_synchronizations, sync_attribute) - Support real-time 2-way value synchronization with recursion guard - Support dynamic container/attribute addition and removal - Support JSON serialization/deserialization of synchronized attributes - Add comprehensive unit tests in test_container_group_sync.cpp --- Meta/include/meta/core/abstract_attribute.hpp | 4 + Meta/include/meta/core/attribute.hpp | 21 +- .../include/meta/core/attribute_container.hpp | 11 + Meta/include/meta/core/container_group.hpp | 79 ++++- Meta/src/attribute_container.cpp | 5 + Meta/src/container_group.cpp | 255 +++++++++++++++- tests/test_qt/test_meta_qt/main.cpp | 13 +- tests/unittests/CMakeLists.txt | 1 + tests/unittests/test_container_group_sync.cpp | 276 ++++++++++++++++++ 9 files changed, 651 insertions(+), 14 deletions(-) create mode 100644 tests/unittests/test_container_group_sync.cpp 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..fe1ba6a 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; /** @@ -93,6 +95,68 @@ class ContainerGroup : public MetaObject /// 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 +199,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..8fa274c 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) @@ -156,11 +249,154 @@ void ContainerGroup::set_current(const std::string &key) 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 +408,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 +461,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 +513,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/tests/test_qt/test_meta_qt/main.cpp b/tests/test_qt/test_meta_qt/main.cpp index 0448626..c3401c9 100644 --- a/tests/test_qt/test_meta_qt/main.cpp +++ b/tests/test_qt/test_meta_qt/main.cpp @@ -776,9 +776,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 +789,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 +807,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 +815,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(); } // ----------------------------------------------------------------------------- diff --git a/tests/unittests/CMakeLists.txt b/tests/unittests/CMakeLists.txt index 4ebdc36..ff0a7fc 100644 --- a/tests/unittests/CMakeLists.txt +++ b/tests/unittests/CMakeLists.txt @@ -23,6 +23,7 @@ add_executable(meta_unittests test_serialization.cpp test_attribute_factory.cpp test_event.cpp + test_container_group_sync.cpp ) target_link_libraries(meta_unittests diff --git a/tests/unittests/test_container_group_sync.cpp b/tests/unittests/test_container_group_sync.cpp new file mode 100644 index 0000000..38025eb --- /dev/null +++ b/tests/unittests/test_container_group_sync.cpp @@ -0,0 +1,276 @@ +/* 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); +} From 6f256131cf06e35bfb5a74d43c0747ef00a3ac8d Mon Sep 17 00:00:00 2001 From: Otto Link Date: Thu, 27 Aug 2026 10:52:54 +0200 Subject: [PATCH 04/14] feat(core): add set_current_to_first method to ContainerGroup --- Meta/include/meta/core/container_group.hpp | 6 ++++ Meta/src/container_group.cpp | 13 ++++++++ tests/unittests/test_container_group_sync.cpp | 31 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/Meta/include/meta/core/container_group.hpp b/Meta/include/meta/core/container_group.hpp index fe1ba6a..8d04b38 100644 --- a/Meta/include/meta/core/container_group.hpp +++ b/Meta/include/meta/core/container_group.hpp @@ -92,6 +92,12 @@ 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(); diff --git a/Meta/src/container_group.cpp b/Meta/src/container_group.cpp index 8fa274c..aee8137 100644 --- a/Meta/src/container_group.cpp +++ b/Meta/src/container_group.cpp @@ -246,6 +246,19 @@ 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"); diff --git a/tests/unittests/test_container_group_sync.cpp b/tests/unittests/test_container_group_sync.cpp index 38025eb..67790e9 100644 --- a/tests/unittests/test_container_group_sync.cpp +++ b/tests/unittests/test_container_group_sync.cpp @@ -274,3 +274,34 @@ TEST(ContainerGroupSyncTest, JsonRoundTripPreservesSync) 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"); +} From 34f428e9371639554f67ba5368a77aaefbec3957 Mon Sep 17 00:00:00 2001 From: Leonhardmaster2 Date: Fri, 28 Aug 2026 08:09:22 +0200 Subject: [PATCH 05/14] feat(qt): pluggable widget designs for attribute rows Adds a design layer under meta_qt so a host can supply an alternative look for attribute rows without forking the renderer, and so a second look costs a registration rather than an edit to shared dispatch. Today a row is produced by three hardcoded cascades: an if-chain on typeid, a WidgetRenderer specialisation, then an if-chain on the widget_type string. WidgetRenderer::render() also does five jobs at once -- read metadata, pick a control, build it, wire control->attribute, wire attribute->control -- and the last two are copy-pasted into every branch. A second visual variant therefore means duplicating the model wiring, which is where the subtle bugs live (a sync landing mid-drag, signal feedback loops, subscription lifetime). Four layers, all additive: - ui/theme.hpp Theme as a value type plus a registry, not process-wide globals; derived colours are exposed as formulas so an accent change propagates. Two colourways ship so the mechanism has more than one occupant. - ui/control.hpp ControlBase / Control. Wheel events are ignored unless focused (wheelEvent is final; override handle_wheel), and the editing flag is only settable via begin_edit/end_edit alongside the matching signal. - ui/binding.hpp bind() -- written once per type, never per design. Owns the sync-vs-edit guard, signal blocking and subscription lifetime for every control of that type. - ui/design_registry.hpp (design, type, widget_type) -> factory, resolving exact, then wildcard, then falling back to the stock renderer. Controls provide can_render() so an attribute a design cannot honour (a rail with no min/max) degrades to stock rather than rendering a [0, 0] range. designs/industrial/ supplies two controls as proof the seam is real: a float slider and a bool toggle. An unregistered design name resolves nothing and every row falls back, so selecting a design that does not exist yields the unmodified stock panel. container_widget gains one ContainerRenderOptions field, row_renderer, threaded to the three row-building sites. Left empty it calls qt::render() exactly as before, so behaviour is unchanged unless a host opts in. --- .../qt/include/meta_qt/container_widget.hpp | 16 +- .../meta_qt/designs/industrial/check_row.hpp | 57 +++ .../meta_qt/designs/industrial/industrial.hpp | 28 ++ .../designs/industrial/param_slider.hpp | 92 ++++ MetaUI/qt/include/meta_qt/ui/binding.hpp | 134 ++++++ MetaUI/qt/include/meta_qt/ui/control.hpp | 136 ++++++ .../qt/include/meta_qt/ui/design_registry.hpp | 129 +++++ MetaUI/qt/include/meta_qt/ui/glide.hpp | 54 +++ MetaUI/qt/include/meta_qt/ui/theme.hpp | 199 ++++++++ .../src/container_widget/container_widget.cpp | 53 ++- .../qt/src/designs/industrial/check_row.cpp | 170 +++++++ .../qt/src/designs/industrial/industrial.cpp | 31 ++ .../src/designs/industrial/param_slider.cpp | 442 ++++++++++++++++++ MetaUI/qt/src/ui/control.cpp | 81 ++++ MetaUI/qt/src/ui/design_registry.cpp | 92 ++++ MetaUI/qt/src/ui/glide.cpp | 61 +++ MetaUI/qt/src/ui/theme.cpp | 177 +++++++ 17 files changed, 1933 insertions(+), 19 deletions(-) create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/check_row.hpp create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/industrial.hpp create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp create mode 100644 MetaUI/qt/include/meta_qt/ui/binding.hpp create mode 100644 MetaUI/qt/include/meta_qt/ui/control.hpp create mode 100644 MetaUI/qt/include/meta_qt/ui/design_registry.hpp create mode 100644 MetaUI/qt/include/meta_qt/ui/glide.hpp create mode 100644 MetaUI/qt/include/meta_qt/ui/theme.hpp create mode 100644 MetaUI/qt/src/designs/industrial/check_row.cpp create mode 100644 MetaUI/qt/src/designs/industrial/industrial.cpp create mode 100644 MetaUI/qt/src/designs/industrial/param_slider.cpp create mode 100644 MetaUI/qt/src/ui/control.cpp create mode 100644 MetaUI/qt/src/ui/design_registry.cpp create mode 100644 MetaUI/qt/src/ui/glide.cpp create mode 100644 MetaUI/qt/src/ui/theme.cpp diff --git a/MetaUI/qt/include/meta_qt/container_widget.hpp b/MetaUI/qt/include/meta_qt/container_widget.hpp index 06e971a..4fd41bf 100644 --- a/MetaUI/qt/include/meta_qt/container_widget.hpp +++ b/MetaUI/qt/include/meta_qt/container_widget.hpp @@ -31,6 +31,14 @@ enum GroupSwitchMode GSM_COMBO_BOX ///< Use a combo box for switching groups }; +/** @brief Builds the widget for a single attribute. + * + * The indirection that lets a host supply an alternative widget design without + * the container layer knowing any design exists. Leave unset for the stock + * renderer; see meta_qt/ui/design_registry.hpp for the registry-backed one. + */ +using AttributeRowRenderer = std::function; + /// Options controlling how attribute containers are rendered. struct ContainerRenderOptions { @@ -41,6 +49,7 @@ struct ContainerRenderOptions 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 = {}; ///< Per-attribute widget builder; empty = stock // clang-format on }; @@ -64,9 +73,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..202fcd1 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/check_row.hpp @@ -0,0 +1,57 @@ +/* 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/industrial.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/industrial.hpp new file mode 100644 index 0000000..643a997 --- /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. + * Everything else resolves to nothing and falls back to the stock renderer, + * which is what keeps a partial design a usable panel rather than a broken one. + * + * Note there is deliberately no "stock" design to register: an unknown design + * name finds no factories and every row falls back, so `design = "stock"` gives + * the unmodified Qt look for free. That makes A/B comparison a settings change + * rather than a build. + */ +void register_design(); + +} // 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..9261bab --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp @@ -0,0 +1,92 @@ +/* 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" + +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: + // --- geometry, recomputed from the row's own width (not the window's) + int label_width() const; + int field_width() const; + QRect rail_rect() const; + QRect thumb_rect() const; + + // --- 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/ui/binding.hpp b/MetaUI/qt/include/meta_qt/ui/binding.hpp new file mode 100644 index 0000000..379443f --- /dev/null +++ b/MetaUI/qt/include/meta_qt/ui/binding.hpp @@ -0,0 +1,134 @@ +/* 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. + * + * 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(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..16d49fd --- /dev/null +++ b/MetaUI/qt/include/meta_qt/ui/design_registry.hpp @@ -0,0 +1,129 @@ +/* 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" + +namespace meta::qt +{ + +/// 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 the row falls back to the stock + * renderer -- 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(attr, *control, *host); + + return host; + }; +} + +/** @brief Maps (design, C++ type, widget_type) to a row factory. + * + * This is what makes a second visual design a registration rather than an edit + * to a dispatch function. The stock renderer resolves type and widget_type + * through two hardcoded if-chains, so every new design or type meant editing + * shared code; here they are table entries. + * + * Lookup order for a given design: + * 1. exact (type, widget_type) + * 2. (type, "*") + * 3. miss -- the caller falls back to the stock meta::qt::render() + * + * That fallback is deliberate and load-bearing: it is what lets a design cover + * three widget types and still leave a completely usable panel. + */ +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); + + /// 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`. + * + * Returns nullptr when the design has nothing registered for this attribute, + * which the caller should treat as "use the stock renderer" rather than as an + * error. See render_row(). + */ + MetaWidget *render(AbstractAttribute *p_attr, + const std::string &design, + const RowContext &ctx, + QWidget *parent = nullptr) 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_; +}; + +/** @brief Render one attribute, falling back to the stock renderer on a miss. + * + * The single entry point a panel should call. Keeps the fallback in one place + * so a partially ported design cannot leave holes in a panel. + */ +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..c23e96b --- /dev/null +++ b/MetaUI/qt/include/meta_qt/ui/glide.hpp @@ -0,0 +1,54 @@ +/* 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..2dee57c --- /dev/null +++ b/MetaUI/qt/include/meta_qt/ui/theme.hpp @@ -0,0 +1,199 @@ +/* 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 + +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; + + // --- 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. + */ +struct Theme +{ + std::string name = "industrial-dark"; + + // --- surfaces + QColor page{"#2b2b2b"}; + QColor bar{"#262626"}; + QColor section_header{"#333333"}; + QColor section_header_hover{"#383838"}; + QColor section_header_press{"#303030"}; + 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"}; + + /// Per-group accents, keyed by attribute category. Falls back to `accent`. + 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 at construction time. Switching requires a restart: + * controls cache brushes and pixmaps derived from the theme, and invalidating + * those on every theme change would cost more than the feature is worth until a + * second colourway actually exists. + */ +class ThemeRegistry +{ +public: + 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(); + + std::map themes_; + std::string fallback_name_; +}; + +} // namespace meta::qt diff --git a/MetaUI/qt/src/container_widget/container_widget.cpp b/MetaUI/qt/src/container_widget/container_widget.cpp index ccdea24..9d0ada2 100644 --- a/MetaUI/qt/src/container_widget/container_widget.cpp +++ b/MetaUI/qt/src/container_widget/container_widget.cpp @@ -63,9 +63,21 @@ void insert_attribute(CategoryNode &root, node->attributes.push_back(attr); } -void render_flat(CategoryNode &node, - QVBoxLayout *layout, - std::vector &collected_widgets) +namespace +{ + +/// 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 +86,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 +103,7 @@ 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 +111,8 @@ void render_category(AttributeContainer &container, QVBoxLayout *parent_layout, std::vector &collected_widgets, std::vector> - &collected_sections) + &collected_sections, + const AttributeRowRenderer &row_renderer) { Logger::log()->trace("container_widget::render_category: '{}'", node.name); @@ -144,7 +157,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 +178,8 @@ void render_category(AttributeContainer &container, *node.children.at(name), current_layout, collected_widgets, - collected_sections); + collected_sections, + row_renderer); } void render_category_merged( @@ -175,7 +189,8 @@ 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) { Logger::log()->trace("container_widget::render_category_merged"); @@ -241,7 +256,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 +280,8 @@ void render_category_merged( layout, collected_widgets, collected_sections, - collapse_regex); + collapse_regex, + row_renderer); } MetaWidget *render(AttributeContainer &container, @@ -338,7 +354,8 @@ MetaWidget *render(AttributeContainer &container, root, layout, collected_widgets, - collected_sections); + collected_sections, + options.row_renderer); break; case CategoryPolicy::CP_MERGED: @@ -348,21 +365,23 @@ MetaWidget *render(AttributeContainer &container, layout, collected_widgets, collected_sections, - options.collapse_regex); + options.collapse_regex, + options.row_renderer); 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, options.row_renderer); else render_category_merged(container, root, layout, collected_widgets, collected_sections, - options.collapse_regex); + options.collapse_regex, + options.row_renderer); break; #pragma GCC diagnostic push @@ -370,7 +389,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, options.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..1aae271 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/check_row.cpp @@ -0,0 +1,170 @@ +/* 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/industrial.cpp b/MetaUI/qt/src/designs/industrial/industrial.cpp new file mode 100644 index 0000000..9b89a50 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/industrial.cpp @@ -0,0 +1,31 @@ +/* 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/param_slider.hpp" +#include "meta_qt/ui/design_registry.hpp" + +namespace meta::qt::industrial +{ + +void register_design() +{ + static bool registered = false; + if (registered) return; + registered = true; + + DesignRegistry ®istry = DesignRegistry::instance(); + + // --- 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"); +} + +} // 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..c45962b --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/param_slider.cpp @@ -0,0 +1,442 @@ +/* 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); +} + +// --- geometry + +int ParamSlider::label_width() const +{ + const Metrics &m = theme().metrics; + return int(std::clamp(width() * m.label_width_ratio, + m.label_min_width, + m.label_max_width)); +} + +int ParamSlider::field_width() const +{ + const Metrics &m = theme().metrics; + // The narrow branch keys off this row's own width, not the window's. + return width() < m.narrow_threshold ? m.value_field_width_narrow + : m.value_field_width; +} + +QRect ParamSlider::rail_rect() const +{ + const Metrics &m = theme().metrics; + const int x0 = label_width() + m.gap; + const int x1 = width() - field_width() - m.gap; + const int y = (height() - m.rail_height) / 2; + return QRect(x0, y, std::max(0, x1 - x0), m.rail_height); +} + +QRect ParamSlider::thumb_rect() const +{ + const Metrics &m = theme().metrics; + const QRect rail = rail_rect(); + const int travel = std::max(0, rail.width() - m.thumb_width); + const int x = rail.x() + int(std::round(norm_ * travel)); + const int y = (height() - m.thumb_height) / 2; + return QRect(x, y, m.thumb_width, m.thumb_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); + painter.setRenderHint(QPainter::Antialiasing, true); + + const Theme &t = theme(); + const Metrics &m = t.metrics; + const bool locked = is_locked(); + + // --- 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(t.state_ink(is_modified(), locked)); + const int label_w = label_width(); + painter.drawText(QRect(0, 0, label_w, height()), + Qt::AlignLeft | Qt::AlignVCenter, + elide_label(QString::fromStdString(label_), label_font, label_w)); + + const QRect rail = rail_rect(); + if (rail.width() <= 0) return; + + // --- rail well + painter.setPen(QPen(t.rail_well_border, 1)); + painter.setBrush(t.rail_well); + painter.drawRoundedRect(QRectF(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. + const QRect thumb = thumb_rect(); + const int fill_w = thumb.center().x() - rail.x(); + if (fill_w > 0) + { + QRect fill = rail.adjusted(0, 0, 0, 0); + fill.setWidth(std::min(fill_w, rail.width())); + painter.setPen(Qt::NoPen); + painter.setBrush(t.rail_fill(category_, locked)); + painter.drawRoundedRect(QRectF(fill).adjusted(0.5, 0.5, -0.5, -0.5), + m.rail_radius, + m.rail_radius); + } + + // --- thumb + painter.setOpacity(locked ? t.locked_thumb_alpha : 1.0); + + QLinearGradient metal(thumb.topLeft(), thumb.bottomLeft()); + metal.setColorAt(0.0, t.thumb_top); + metal.setColorAt(1.0, t.thumb_bottom); + + painter.setPen(QPen(t.thumb_border, 1)); + painter.setBrush(metal); + painter.drawRoundedRect(QRectF(thumb).adjusted(0.5, 0.5, -0.5, -0.5), + m.radius, + m.radius); + + // grip notch, 2x8 centred + painter.setPen(Qt::NoPen); + painter.setBrush(t.thumb_grip); + painter.drawRect(QRect(thumb.center().x(), thumb.center().y() - 3, 2, 8)); + + painter.setOpacity(1.0); +} + +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; + } + + const Metrics &m = theme().metrics; + const int fw = field_width(); + field_->setGeometry(width() - fw, + (height() - m.value_field_height) / 2, + fw, + m.value_field_height); + + QWidget::resizeEvent(event); +} + +// --- interaction + +void ParamSlider::mousePressEvent(QMouseEvent *event) +{ + if (is_locked() || event->button() != Qt::LeftButton) + { + event->ignore(); + return; + } + + const QRect rail = rail_rect(); + 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 QRect rail = rail_rect(); + const int travel = std::max(1, rail.width() - m.thumb_width); + + apply_norm(std::clamp(qreal(x - 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/ui/control.cpp b/MetaUI/qt/src/ui/control.cpp new file mode 100644 index 0000000..6b4bcf0 --- /dev/null +++ b/MetaUI/qt/src/ui/control.cpp @@ -0,0 +1,81 @@ +/* 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..4d8f38a --- /dev/null +++ b/MetaUI/qt/src/ui/design_registry.cpp @@ -0,0 +1,92 @@ +/* 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 "meta/logger.hpp" + +#include "meta_qt/widget_renderer.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); +} + +MetaWidget *DesignRegistry::render(AbstractAttribute *p_attr, + const std::string &design, + const RowContext &ctx, + QWidget *parent) const +{ + if (!p_attr) return nullptr; + + auto design_it = factories_.find(design); + if (design_it == factories_.end()) return nullptr; + + const auto &table = design_it->second; + + // 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()}; + + if (auto it = table.find(Key{type, widget_type}); it != table.end()) + return it->second(*p_attr, ctx, parent); + + if (auto it = table.find(Key{type, kAnyWidgetType}); it != table.end()) + return it->second(*p_attr, ctx, parent); + + return nullptr; +} + +bool DesignRegistry::has_design(const std::string &design) const +{ + return factories_.find(design) != factories_.end(); +} + +std::vector DesignRegistry::designs() const +{ + std::vector out; + out.reserve(factories_.size()); + for (const auto &[name, _] : factories_) + 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; + } + + if (MetaWidget *row = DesignRegistry::instance().render(p_attr, design, ctx, parent)) + return row; + + // Either nothing is registered for this (design, type, widget_type), or the + // registered control declined the attribute via can_render(). The stock + // renderer covers every type Meta supports, so an unported -- or unrenderable + // -- attribute degrades to a plain widget rather than leaving a gap. + return render(p_attr, parent); +} + +} // 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..829c11f --- /dev/null +++ b/MetaUI/qt/src/ui/theme.cpp @@ -0,0 +1,177 @@ +/* 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 + +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) +{ + QFont font; + font.setPixelSize(pixel_size); + font.setBold(bold); + if (letter_spacing != 0.0) + font.setLetterSpacing(QFont::AbsoluteSpacing, letter_spacing); + return font; +} + +// --- Theme + +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 +{ + +/// The reference colourway, sampled from a render rather than copied from notes. +Theme make_industrial_dark() +{ + Theme t; + t.name = "industrial-dark"; + return t; // the struct defaults *are* industrial-dark +} + +/** @brief A light colourway over the same geometry. + * + * Present so the theme layer has more than one occupant from day one -- a + * mechanism with a single implementation is untested by construction, and this + * is what proves the palette is genuinely swappable rather than nominally so. + */ +Theme make_industrial_light() +{ + Theme t; + t.name = "industrial-light"; + + t.page = QColor("#d9d9d9"); + t.bar = QColor("#cfcfcf"); + t.section_header = QColor("#c4c4c4"); + t.section_header_hover = QColor("#bcbcbc"); + t.section_header_press = QColor("#c9c9c9"); + t.rail_well = QColor("#b8b8b8"); + t.field = QColor("#ececec"); + t.field_hover = QColor("#e2e2e2"); + t.field_editing = QColor("#ffffff"); + t.switch_track_off = QColor("#b8b8b8"); + + t.bevel_top = QColor("#e8e8e8"); + t.bevel_bottom = QColor("#b0b0b0"); + t.hairline = QColor("#a8a8a8"); + t.rail_well_border = QColor("#9e9e9e"); + t.field_border = QColor("#8a8a8a"); + t.field_border_hover = QColor("#6e6e6e"); + + t.ink_primary = QColor("#1f1f1f"); + t.ink_section_title = QColor("#2b2b2b"); + t.ink_secondary = QColor("#5a5a5a"); + t.ink_dim = QColor("#6e6e6e"); + t.ink_locked = QColor("#a0a0a0"); + t.ink_modified = QColor("#000000"); + t.ink_icon = QColor("#3a3a3a"); + + t.thumb_top = QColor("#fbfbfb"); + t.thumb_bottom = QColor("#d0d0d0"); + t.thumb_border = QColor("#8a8a8a"); + t.thumb_grip = QColor("#a8a8a8"); + t.knob_on_top = QColor("#ffffff"); + t.knob_on_bottom = QColor("#e0e0e0"); + t.knob_off_top = QColor("#f0f0f0"); + t.knob_off_bottom = QColor("#d4d4d4"); + + return t; +} + +} // namespace + +ThemeRegistry::ThemeRegistry() +{ + add(make_industrial_dark()); + add(make_industrial_light()); + fallback_name_ = "industrial-dark"; +} + +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 +{ + auto it = themes_.find(name); + return it == themes_.end() ? fallback() : it->second; +} + +const Theme &ThemeRegistry::fallback() const +{ + return themes_.at(fallback_name_); +} + +std::vector ThemeRegistry::names() const +{ + std::vector out; + out.reserve(themes_.size()); + for (const auto &[name, _] : themes_) + out.push_back(name); + return out; +} + +} // namespace meta::qt From 556fe1ffca74b6198b13ba3556a7d6c04f676c98 Mon Sep 17 00:00:00 2001 From: Leonhardmaster2 Date: Tue, 1 Sep 2026 15:02:13 +0200 Subject: [PATCH 06/14] feat(qt): register the stock widgets as a design Follow-up to the design layer: "stock" becomes a flavour registered like any other rather than a fallback hardcoded into the dispatcher, so every design sits at the same level in the hierarchy. designs/stock wraps the existing WidgetRenderer for each supported type, registered under the wildcard widget_type since WidgetRenderer already resolves widget_type itself. No stock widget is rewritten and WidgetRenderer stays a public compile-time extension point -- only its use as a dispatcher goes away. Splitting the inner widget_type if-chains into separate entries would remove those too, but that is a rewrite rather than a registration and is left for later. render_row() now reduces to a single DesignRegistry::render() call with no hardcoded renderer behind it. Resolution gains a fallback chain, expressed as data rather than as a special case in code: set_fallback("industrial", "stock") means a widget type the industrial design has not covered yet resolves through stock instead of rendering nothing. Without it a design under construction would drop every unported row, which is what the old hardcoded fallback existed to prevent. Leave the fallback unset and an unregistered type renders nothing, as expected. Cycles are ignored rather than followed, and a factory returning nullptr -- can_render() declining -- resumes the same walk. --- .../meta_qt/designs/industrial/industrial.hpp | 14 ++-- .../include/meta_qt/designs/stock/stock.hpp | 29 ++++++++ .../qt/include/meta_qt/ui/design_registry.hpp | 57 ++++++++++------ .../qt/src/designs/industrial/industrial.cpp | 7 ++ MetaUI/qt/src/designs/stock/stock.cpp | 68 +++++++++++++++++++ MetaUI/qt/src/ui/design_registry.cpp | 59 +++++++++++----- 6 files changed, 190 insertions(+), 44 deletions(-) create mode 100644 MetaUI/qt/include/meta_qt/designs/stock/stock.hpp create mode 100644 MetaUI/qt/src/designs/stock/stock.cpp diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/industrial.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/industrial.hpp index 643a997..ac7f93f 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/industrial.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/industrial.hpp @@ -14,14 +14,14 @@ inline constexpr char kDesignName[] = "industrial"; * * Idempotent, so a host may call it unconditionally at startup. * - * Only the widget types this design actually implements are registered. - * Everything else resolves to nothing and falls back to the stock renderer, - * which is what keeps a partial design a usable panel rather than a broken one. + * 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. * - * Note there is deliberately no "stock" design to register: an unknown design - * name finds no factories and every row falls back, so `design = "stock"` gives - * the unmodified Qt look for free. That makes A/B comparison a settings change - * rather than a build. + * "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(); 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/design_registry.hpp b/MetaUI/qt/include/meta_qt/ui/design_registry.hpp index 16d49fd..c6680c2 100644 --- a/MetaUI/qt/include/meta_qt/ui/design_registry.hpp +++ b/MetaUI/qt/include/meta_qt/ui/design_registry.hpp @@ -34,10 +34,11 @@ inline constexpr char kAnyWidgetType[] = "*"; * bind(). * * A control must also provide `static bool can_render(const Attribute &)`. - * Returning false declines the attribute and the row falls back to the stock - * renderer -- 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. + * 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() { @@ -61,18 +62,21 @@ template RowFactory make_row_factory() /** @brief Maps (design, C++ type, widget_type) to a row factory. * - * This is what makes a second visual design a registration rather than an edit - * to a dispatch function. The stock renderer resolves type and widget_type - * through two hardcoded if-chains, so every new design or type meant editing - * shared code; here they are table entries. + * 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. * - * Lookup order for a given design: + * 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. miss -- the caller falls back to the stock meta::qt::render() + * 3. otherwise continue with the fallback design, if one is set * - * That fallback is deliberate and load-bearing: it is what lets a design cover - * three widget types and still leave a completely usable panel. + * A factory returning nullptr counts as a miss, so a control declining an + * attribute via can_render() resumes the same walk. */ class DesignRegistry { @@ -85,6 +89,19 @@ class DesignRegistry 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) @@ -92,11 +109,11 @@ class DesignRegistry add(design, std::type_index(typeid(T)), widget_type, make_row_factory()); } - /** @brief Build a row for `p_attr` using `design`. + /** @brief Build a row for `p_attr` using `design`, following its fallbacks. * - * Returns nullptr when the design has nothing registered for this attribute, - * which the caller should treat as "use the stock renderer" rather than as an - * error. See render_row(). + * 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, @@ -114,12 +131,14 @@ class DesignRegistry using Key = std::pair; std::map> factories_; + std::map fallbacks_; }; -/** @brief Render one attribute, falling back to the stock renderer on a miss. +/** @brief Render one attribute using `design`. * - * The single entry point a panel should call. Keeps the fallback in one place - * so a partially ported design cannot leave holes in a panel. + * 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, diff --git a/MetaUI/qt/src/designs/industrial/industrial.cpp b/MetaUI/qt/src/designs/industrial/industrial.cpp index 9b89a50..5639b9e 100644 --- a/MetaUI/qt/src/designs/industrial/industrial.cpp +++ b/MetaUI/qt/src/designs/industrial/industrial.cpp @@ -5,6 +5,7 @@ #include "meta_qt/designs/industrial/check_row.hpp" #include "meta_qt/designs/industrial/param_slider.hpp" +#include "meta_qt/designs/stock/stock.hpp" #include "meta_qt/ui/design_registry.hpp" namespace meta::qt::industrial @@ -26,6 +27,12 @@ void register_design() // branch inside CheckRow. registry.register_control(kDesignName, "Toggle"); registry.register_control(kDesignName, "Checkbox"); + + // 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/stock/stock.cpp b/MetaUI/qt/src/designs/stock/stock.cpp new file mode 100644 index 0000000..4c2b07a --- /dev/null +++ b/MetaUI/qt/src/designs/stock/stock.cpp @@ -0,0 +1,68 @@ +/* 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 "meta_qt/ui/design_registry.hpp" +#include "meta_qt/widget_renderer.hpp" + +namespace meta::qt::stock +{ + +namespace +{ + +/** @brief Wrap WidgetRenderer as a row factory. + * + * Registered under the wildcard widget_type: WidgetRenderer already + * branches on widget_type internally, so splitting those branches into + * separate entries would be a rewrite rather than a registration. Doing that + * later would remove the inner if-chains too, but it is not needed to make the + * registry the single dispatch point. + */ +template RowFactory wrap() +{ + return [](AbstractAttribute &attr, const RowContext &, QWidget *parent) -> MetaWidget * + { return WidgetRenderer::render(static_cast &>(attr), parent); }; +} + +template void add(DesignRegistry ®istry) +{ + registry.add(kDesignName, std::type_index(typeid(T)), kAnyWidgetType, wrap()); +} + +} // namespace + +void register_design() +{ + static bool registered = false; + if (registered) return; + registered = true; + + DesignRegistry ®istry = DesignRegistry::instance(); + + add(registry); + add(registry); + add(registry); + add(registry); + add(registry); + add>(registry); + +#ifdef META_ENABLE_GLM_TYPES + add(registry); + add(registry); + add(registry); + add(registry); + add>(registry); +#endif + +#ifdef META_ENABLE_COLOR_GRADIENT_TYPES + add(registry); +#endif + +#ifdef META_ENABLE_ARRAY_TYPES + add(registry); +#endif +} + +} // namespace meta::qt::stock diff --git a/MetaUI/qt/src/ui/design_registry.cpp b/MetaUI/qt/src/ui/design_registry.cpp index 4d8f38a..6e1d2f1 100644 --- a/MetaUI/qt/src/ui/design_registry.cpp +++ b/MetaUI/qt/src/ui/design_registry.cpp @@ -3,9 +3,9 @@ this software. */ #include "meta_qt/ui/design_registry.hpp" -#include "meta/logger.hpp" +#include -#include "meta_qt/widget_renderer.hpp" +#include "meta/logger.hpp" namespace meta::qt { @@ -24,6 +24,11 @@ void DesignRegistry::add(const std::string &design, 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, @@ -31,11 +36,6 @@ MetaWidget *DesignRegistry::render(AbstractAttribute *p_attr, { if (!p_attr) return nullptr; - auto design_it = factories_.find(design); - if (design_it == factories_.end()) return nullptr; - - const auto &table = design_it->second; - // 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( @@ -45,11 +45,31 @@ MetaWidget *DesignRegistry::render(AbstractAttribute *p_attr, const std::type_index type{p_attr->type()}; - if (auto it = table.find(Key{type, widget_type}); it != table.end()) - return it->second(*p_attr, ctx, parent); + std::set visited; + std::string current = design; - if (auto it = table.find(Key{type, kAnyWidgetType}); it != table.end()) - return it->second(*p_attr, ctx, parent); + 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; } @@ -79,14 +99,17 @@ MetaWidget *render_row(AbstractAttribute *p_attr, return nullptr; } - if (MetaWidget *row = DesignRegistry::instance().render(p_attr, design, ctx, parent)) - return row; + 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()); - // Either nothing is registered for this (design, type, widget_type), or the - // registered control declined the attribute via can_render(). The stock - // renderer covers every type Meta supports, so an unported -- or unrenderable - // -- attribute degrades to a plain widget rather than leaving a gap. - return render(p_attr, parent); + return row; } } // namespace meta::qt From a1fd9472345ee0a8aa625dc6fd9a01e76c336381 Mon Sep 17 00:00:00 2001 From: Leonhardmaster2 Date: Tue, 1 Sep 2026 16:43:14 +0200 Subject: [PATCH 07/14] feat(qt): derive the base theme from QPalette The design only worked against the one colour scheme it was sampled from: every surface, hairline and ink value was a hardcoded hex. Theme::from_palette() derives them from a QPalette instead, so the look sits on top of whatever scheme the host runs. Two derivation rules, picked so the same code works on light and dark schemes without branching on which one it is: - Surfaces blend towards black or white. A recessed rail well is darker and a raised bevel lighter in both schemes, whereas darker() on a near-black window barely moves. - Dimmed ink blends towards the window colour, so "less prominent" resolves to darker on a light scheme and lighter on a dark one by construction. Accent comes from QPalette::Highlight rather than being imposed. Group accents stay hardcoded on purpose: they encode which family an operation belongs to, so they have to stay distinguishable from each other rather than track a host accent. ThemeRegistry now defaults to "palette", built lazily from the application palette because the registry is a static singleton that may be constructed before QApplication exists. The sampled colourway remains available as "industrial-dark" for pinning the reference look. The struct's member initialisers still hold the reference values. They document what was sampled and keep a default-constructed Theme paintable, but they are no longer the intended source of colour. --- MetaUI/qt/include/meta_qt/ui/theme.hpp | 55 +++++++-- MetaUI/qt/src/ui/theme.cpp | 161 +++++++++++++++++-------- 2 files changed, 156 insertions(+), 60 deletions(-) diff --git a/MetaUI/qt/include/meta_qt/ui/theme.hpp b/MetaUI/qt/include/meta_qt/ui/theme.hpp index 2dee57c..68b8a45 100644 --- a/MetaUI/qt/include/meta_qt/ui/theme.hpp +++ b/MetaUI/qt/include/meta_qt/ui/theme.hpp @@ -8,6 +8,7 @@ #include #include +#include namespace meta::qt { @@ -80,11 +81,35 @@ struct Metrics * * 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"}; @@ -127,7 +152,13 @@ struct Theme // --- accent QColor accent{"#e08a2e"}; - /// Per-group accents, keyed by attribute category. Falls back to `accent`. + /** @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")}, @@ -163,14 +194,21 @@ struct Theme /** @brief Owns the built-in themes and any registered by a host. * - * Themes are resolved once at construction time. Switching requires a restart: - * controls cache brushes and pixmaps derived from the theme, and invalidating - * those on every theme change would cost more than the feature is worth until a - * second colourway actually exists. + * 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. @@ -192,8 +230,11 @@ class ThemeRegistry private: ThemeRegistry(); - std::map themes_; - std::string fallback_name_; + /// 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/src/ui/theme.cpp b/MetaUI/qt/src/ui/theme.cpp index 829c11f..8acbab4 100644 --- a/MetaUI/qt/src/ui/theme.cpp +++ b/MetaUI/qt/src/ui/theme.cpp @@ -3,6 +3,7 @@ this software. */ #include "meta_qt/ui/theme.hpp" +#include #include namespace meta::qt @@ -50,6 +51,92 @@ QFont ui_font(int pixel_size, bool bold, qreal letter_spacing) // --- 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); + t.section_header = lift(window, 0.06); + t.section_header_hover = lift(window, 0.10); + t.section_header_press = lift(window, 0.03); + 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); @@ -76,61 +163,17 @@ QColor Theme::state_ink(bool modified, bool locked) const namespace { -/// The reference colourway, sampled from a render rather than copied from notes. -Theme make_industrial_dark() -{ - Theme t; - t.name = "industrial-dark"; - return t; // the struct defaults *are* industrial-dark -} - -/** @brief A light colourway over the same geometry. +/** @brief The reference colourway, sampled from a render rather than notes. * - * Present so the theme layer has more than one occupant from day one -- a - * mechanism with a single implementation is untested by construction, and this - * is what proves the palette is genuinely swappable rather than nominally so. + * 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_light() +Theme make_industrial_dark() { Theme t; - t.name = "industrial-light"; - - t.page = QColor("#d9d9d9"); - t.bar = QColor("#cfcfcf"); - t.section_header = QColor("#c4c4c4"); - t.section_header_hover = QColor("#bcbcbc"); - t.section_header_press = QColor("#c9c9c9"); - t.rail_well = QColor("#b8b8b8"); - t.field = QColor("#ececec"); - t.field_hover = QColor("#e2e2e2"); - t.field_editing = QColor("#ffffff"); - t.switch_track_off = QColor("#b8b8b8"); - - t.bevel_top = QColor("#e8e8e8"); - t.bevel_bottom = QColor("#b0b0b0"); - t.hairline = QColor("#a8a8a8"); - t.rail_well_border = QColor("#9e9e9e"); - t.field_border = QColor("#8a8a8a"); - t.field_border_hover = QColor("#6e6e6e"); - - t.ink_primary = QColor("#1f1f1f"); - t.ink_section_title = QColor("#2b2b2b"); - t.ink_secondary = QColor("#5a5a5a"); - t.ink_dim = QColor("#6e6e6e"); - t.ink_locked = QColor("#a0a0a0"); - t.ink_modified = QColor("#000000"); - t.ink_icon = QColor("#3a3a3a"); - - t.thumb_top = QColor("#fbfbfb"); - t.thumb_bottom = QColor("#d0d0d0"); - t.thumb_border = QColor("#8a8a8a"); - t.thumb_grip = QColor("#a8a8a8"); - t.knob_on_top = QColor("#ffffff"); - t.knob_on_bottom = QColor("#e0e0e0"); - t.knob_off_top = QColor("#f0f0f0"); - t.knob_off_bottom = QColor("#d4d4d4"); - - return t; + t.name = "industrial-dark"; + return t; // the struct's member initialisers are industrial-dark } } // namespace @@ -138,8 +181,15 @@ Theme make_industrial_light() ThemeRegistry::ThemeRegistry() { add(make_industrial_dark()); - add(make_industrial_light()); - fallback_name_ = "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() @@ -156,17 +206,22 @@ void ThemeRegistry::add(Theme 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_) From 2ba3c871ed3758cb4c7d3d29d4d4f40c8f695e4e Mon Sep 17 00:00:00 2001 From: Leonhardmaster2 Date: Tue, 1 Sep 2026 16:50:41 +0200 Subject: [PATCH 08/14] feat(qt): add the industrial int slider and section chrome SliderInt covers roughly 13% of the rows in a Hesiod node panel once Seed is counted, and was the largest remaining stock element after float and bool. Rather than copy ParamSlider, the row geometry and painting move into slider_chrome and both sliders call it. The two rows cannot drift apart, and the duplication is limited to value semantics, which genuinely differ: the int rail quantises to whole values, the glide drives the painted position only, and the readout is set up front. A float slider derives its value from the animation instead, which for an int would briefly show something the model cannot hold. One wheel notch is one unit rather than a percentage of the range. Section chrome needs a different seam. A section is not bound to an attribute, so it cannot resolve through the design registry; ContainerRenderOptions gains a section_factory alongside row_renderer, empty meaning the stock section. The industrial factory returns a stock CollapsibleSection wearing a theme-derived stylesheet rather than a subclass, which restyles the header without widening CollapsibleSection's interface. That header is a checked QToolButton, which most styles paint in the platform highlight colour. It is why an unstyled panel showed blue bars between grey rows. --- .../qt/include/meta_qt/container_widget.hpp | 11 + .../meta_qt/designs/industrial/int_slider.hpp | 77 +++++ .../designs/industrial/param_slider.hpp | 7 +- .../meta_qt/designs/industrial/section.hpp | 23 ++ .../designs/industrial/slider_chrome.hpp | 57 ++++ .../src/container_widget/container_widget.cpp | 32 +- .../qt/src/designs/industrial/industrial.cpp | 5 + .../qt/src/designs/industrial/int_slider.cpp | 306 ++++++++++++++++++ .../src/designs/industrial/param_slider.cpp | 121 ++----- MetaUI/qt/src/designs/industrial/section.cpp | 65 ++++ .../src/designs/industrial/slider_chrome.cpp | 131 ++++++++ 11 files changed, 719 insertions(+), 116 deletions(-) create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/section.hpp create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp create mode 100644 MetaUI/qt/src/designs/industrial/int_slider.cpp create mode 100644 MetaUI/qt/src/designs/industrial/section.cpp create mode 100644 MetaUI/qt/src/designs/industrial/slider_chrome.cpp diff --git a/MetaUI/qt/include/meta_qt/container_widget.hpp b/MetaUI/qt/include/meta_qt/container_widget.hpp index 4fd41bf..f4d2ab0 100644 --- a/MetaUI/qt/include/meta_qt/container_widget.hpp +++ b/MetaUI/qt/include/meta_qt/container_widget.hpp @@ -11,6 +11,7 @@ #include "meta_common.hpp" #include "meta_qt/meta_widget.hpp" +#include "meta_qt/widgets/collapsible_section.hpp" namespace meta::qt { @@ -39,6 +40,15 @@ enum GroupSwitchMode */ using AttributeRowRenderer = std::function; +/** @brief Builds the collapsible section used for a category. + * + * Same indirection as AttributeRowRenderer, for the chrome around the rows + * rather than the rows themselves. A section is not bound to an attribute, so + * it cannot go through the design registry; it is supplied here instead. + * Leave unset for the stock section. + */ +using SectionFactory = std::function; + /// Options controlling how attribute containers are rendered. struct ContainerRenderOptions { @@ -50,6 +60,7 @@ struct ContainerRenderOptions std::optional collapse_regex = std::nullopt; ///< Regex used to collapse categories bool snapshot_manager = false; ///< Add snapshot manager widget AttributeRowRenderer row_renderer = {}; ///< Per-attribute widget builder; empty = stock + SectionFactory section_factory = {}; ///< Category section builder; empty = stock // clang-format on }; 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..a888aab --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp @@ -0,0 +1,77 @@ +/* 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/param_slider.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp index 9261bab..b19204d 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp @@ -6,6 +6,7 @@ #include "meta_common.hpp" +#include "meta_qt/designs/industrial/slider_chrome.hpp" #include "meta_qt/ui/control.hpp" #include "meta_qt/ui/glide.hpp" @@ -57,12 +58,6 @@ class ParamSlider : public Control bool eventFilter(QObject *watched, QEvent *event) override; private: - // --- geometry, recomputed from the row's own width (not the window's) - int label_width() const; - int field_width() const; - QRect rail_rect() const; - QRect thumb_rect() const; - // --- value <-> normalised position qreal to_norm(float value) const; float from_norm(qreal t) const; 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..0c3d4ef --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp @@ -0,0 +1,23 @@ +/* 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" + +namespace meta::qt::industrial +{ + +/** @brief Section factory matching the industrial rows. + * + * Returns a stock CollapsibleSection wearing a theme-derived stylesheet rather + * than a subclass. The header is a QToolButton, and a stylesheet can restyle it + * completely without reaching into CollapsibleSection's internals, so the + * design gets its look without Meta having to widen that class's interface. + * + * `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..c89f61d --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp @@ -0,0 +1,57 @@ +/* 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_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 +{ + int label_width = 0; + int field_width = 0; + QRect rail; + QRect thumb; + QRect field; + + 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/src/container_widget/container_widget.cpp b/MetaUI/qt/src/container_widget/container_widget.cpp index 9d0ada2..8f45993 100644 --- a/MetaUI/qt/src/container_widget/container_widget.cpp +++ b/MetaUI/qt/src/container_widget/container_widget.cpp @@ -66,6 +66,13 @@ void insert_attribute(CategoryNode &root, 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) { @@ -112,7 +119,8 @@ void render_category(AttributeContainer &container, std::vector &collected_widgets, std::vector> &collected_sections, - const AttributeRowRenderer &row_renderer) + const AttributeRowRenderer &row_renderer, + const SectionFactory §ion_factory) { Logger::log()->trace("container_widget::render_category: '{}'", node.name); @@ -122,7 +130,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 '{}'", @@ -179,7 +187,8 @@ void render_category(AttributeContainer &container, current_layout, collected_widgets, collected_sections, - row_renderer); + row_renderer, + section_factory); } void render_category_merged( @@ -190,7 +199,8 @@ void render_category_merged( std::vector> &collected_sections, const std::optional &collapse_regex, - const AttributeRowRenderer &row_renderer) + const AttributeRowRenderer &row_renderer, + const SectionFactory §ion_factory) { Logger::log()->trace("container_widget::render_category_merged"); @@ -218,7 +228,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); @@ -281,7 +291,8 @@ void render_category_merged( collected_widgets, collected_sections, collapse_regex, - row_renderer); + row_renderer, + section_factory); } MetaWidget *render(AttributeContainer &container, @@ -355,7 +366,8 @@ MetaWidget *render(AttributeContainer &container, layout, collected_widgets, collected_sections, - options.row_renderer); + options.row_renderer, + options.section_factory); break; case CategoryPolicy::CP_MERGED: @@ -366,7 +378,8 @@ MetaWidget *render(AttributeContainer &container, collected_widgets, collected_sections, options.collapse_regex, - options.row_renderer); + options.row_renderer, + options.section_factory); break; case CategoryPolicy::CP_SMART: @@ -381,7 +394,8 @@ MetaWidget *render(AttributeContainer &container, collected_widgets, collected_sections, options.collapse_regex, - options.row_renderer); + options.row_renderer, + options.section_factory); break; #pragma GCC diagnostic push diff --git a/MetaUI/qt/src/designs/industrial/industrial.cpp b/MetaUI/qt/src/designs/industrial/industrial.cpp index 5639b9e..0d853a5 100644 --- a/MetaUI/qt/src/designs/industrial/industrial.cpp +++ b/MetaUI/qt/src/designs/industrial/industrial.cpp @@ -4,6 +4,7 @@ #include "meta_qt/designs/industrial/industrial.hpp" #include "meta_qt/designs/industrial/check_row.hpp" +#include "meta_qt/designs/industrial/int_slider.hpp" #include "meta_qt/designs/industrial/param_slider.hpp" #include "meta_qt/designs/stock/stock.hpp" #include "meta_qt/ui/design_registry.hpp" @@ -28,6 +29,10 @@ void register_design() 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"); + // 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. 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..01646ee --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/int_slider.cpp @@ -0,0 +1,306 @@ +/* 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/param_slider.cpp b/MetaUI/qt/src/designs/industrial/param_slider.cpp index c45962b..08986e5 100644 --- a/MetaUI/qt/src/designs/industrial/param_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/param_slider.cpp @@ -128,43 +128,6 @@ QSize ParamSlider::sizeHint() const return QSize(theme().metrics.label_min_width + 200, theme().metrics.row_height); } -// --- geometry - -int ParamSlider::label_width() const -{ - const Metrics &m = theme().metrics; - return int(std::clamp(width() * m.label_width_ratio, - m.label_min_width, - m.label_max_width)); -} - -int ParamSlider::field_width() const -{ - const Metrics &m = theme().metrics; - // The narrow branch keys off this row's own width, not the window's. - return width() < m.narrow_threshold ? m.value_field_width_narrow - : m.value_field_width; -} - -QRect ParamSlider::rail_rect() const -{ - const Metrics &m = theme().metrics; - const int x0 = label_width() + m.gap; - const int x1 = width() - field_width() - m.gap; - const int y = (height() - m.rail_height) / 2; - return QRect(x0, y, std::max(0, x1 - x0), m.rail_height); -} - -QRect ParamSlider::thumb_rect() const -{ - const Metrics &m = theme().metrics; - const QRect rail = rail_rect(); - const int travel = std::max(0, rail.width() - m.thumb_width); - const int x = rail.x() + int(std::round(norm_ * travel)); - const int y = (height() - m.thumb_height) / 2; - return QRect(x, y, m.thumb_width, m.thumb_height); -} - // --- value mapping qreal ParamSlider::to_norm(float value) const @@ -198,68 +161,28 @@ float ParamSlider::from_norm(qreal t) const // --- painting + void ParamSlider::paintEvent(QPaintEvent *) { QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing, true); - const Theme &t = theme(); - const Metrics &m = t.metrics; - const bool locked = is_locked(); + const SliderGeometry geometry = SliderGeometry::compute(theme(), + width(), + height(), + norm_); + + SliderVisual visual; + visual.category = category_; + visual.modified = is_modified(); + visual.locked = is_locked(); - // --- 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(t.state_ink(is_modified(), locked)); - const int label_w = label_width(); - painter.drawText(QRect(0, 0, label_w, height()), - Qt::AlignLeft | Qt::AlignVCenter, - elide_label(QString::fromStdString(label_), label_font, label_w)); - - const QRect rail = rail_rect(); - if (rail.width() <= 0) return; - - // --- rail well - painter.setPen(QPen(t.rail_well_border, 1)); - painter.setBrush(t.rail_well); - painter.drawRoundedRect(QRectF(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. - const QRect thumb = thumb_rect(); - const int fill_w = thumb.center().x() - rail.x(); - if (fill_w > 0) - { - QRect fill = rail.adjusted(0, 0, 0, 0); - fill.setWidth(std::min(fill_w, rail.width())); - painter.setPen(Qt::NoPen); - painter.setBrush(t.rail_fill(category_, locked)); - painter.drawRoundedRect(QRectF(fill).adjusted(0.5, 0.5, -0.5, -0.5), - m.rail_radius, - m.rail_radius); - } - - // --- thumb - painter.setOpacity(locked ? t.locked_thumb_alpha : 1.0); - - QLinearGradient metal(thumb.topLeft(), thumb.bottomLeft()); - metal.setColorAt(0.0, t.thumb_top); - metal.setColorAt(1.0, t.thumb_bottom); - - painter.setPen(QPen(t.thumb_border, 1)); - painter.setBrush(metal); - painter.drawRoundedRect(QRectF(thumb).adjusted(0.5, 0.5, -0.5, -0.5), - m.radius, - m.radius); - - // grip notch, 2x8 centred - painter.setPen(Qt::NoPen); - painter.setBrush(t.thumb_grip); - painter.drawRect(QRect(thumb.center().x(), thumb.center().y() - 3, 2, 8)); + visual.label = elide_label(QString::fromStdString(label_), + label_font, + geometry.label_width); - painter.setOpacity(1.0); + paint_slider_row(painter, theme(), geometry, visual, height()); } void ParamSlider::resizeEvent(QResizeEvent *event) @@ -272,12 +195,7 @@ void ParamSlider::resizeEvent(QResizeEvent *event) return; } - const Metrics &m = theme().metrics; - const int fw = field_width(); - field_->setGeometry(width() - fw, - (height() - m.value_field_height) / 2, - fw, - m.value_field_height); + field_->setGeometry(SliderGeometry::compute(theme(), width(), height(), norm_).field); QWidget::resizeEvent(event); } @@ -292,7 +210,7 @@ void ParamSlider::mousePressEvent(QMouseEvent *event) return; } - const QRect rail = rail_rect(); + const QRect rail = SliderGeometry::compute(theme(), width(), height(), norm_).rail; if (!rail.adjusted(-4, -10, 4, 10).contains(event->pos())) { event->ignore(); @@ -385,9 +303,10 @@ bool ParamSlider::eventFilter(QObject *watched, QEvent *event) void ParamSlider::set_from_position(int x) { - const Metrics &m = theme().metrics; - const QRect rail = rail_rect(); - const int travel = std::max(1, rail.width() - m.thumb_width); + const Metrics &m = theme().metrics; + const SliderGeometry g = SliderGeometry::compute(theme(), width(), height(), norm_); + const QRect rail = g.rail; + const int travel = std::max(1, rail.width() - m.thumb_width); apply_norm(std::clamp(qreal(x - rail.x() - m.thumb_width / 2) / travel, 0.0, 1.0)); } diff --git a/MetaUI/qt/src/designs/industrial/section.cpp b/MetaUI/qt/src/designs/industrial/section.cpp new file mode 100644 index 0000000..79228b1 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/section.cpp @@ -0,0 +1,65 @@ +/* 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 "meta_qt/widgets/collapsible_section.hpp" + +namespace meta::qt::industrial +{ + +SectionFactory make_section_factory(const Theme &theme) +{ + return [&theme](const QString &title) -> CollapsibleSection * + { + auto *section = new CollapsibleSection(title); + + const Metrics &m = theme.metrics; + + // The header is the section's QToolButton. Styling it by type selector + // avoids depending on any private member of CollapsibleSection. + // + // 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. + const QString style = + QString("QToolButton {" + " background: %1;" + " color: %2;" + " border: none;" + " border-top: 1px solid %3;" + " border-bottom: 1px solid %4;" + " border-radius: %5px;" + " padding: 0px 8px;" + " min-height: %6px;" + " text-align: left;" + " font-weight: bold;" + "}" + "QToolButton:hover { background: %7; }" + "QToolButton:pressed { background: %8; }" + "QToolButton:checked { background: %1; color: %2; }") + .arg(theme.section_header.name()) + .arg(theme.ink_section_title.name()) + .arg(theme.bevel_top.name()) + .arg(theme.bevel_bottom.name()) + .arg(m.radius) + .arg(m.section_header_height) + .arg(theme.section_header_hover.name()) + .arg(theme.section_header_press.name()); + + section->setStyleSheet(style); + + if (section->content_layout) + { + section->content_layout->setContentsMargins(m.section_body_padding_x, + m.section_body_padding_y, + m.section_body_padding_x, + m.section_body_padding_y); + section->content_layout->setSpacing(m.section_row_spacing); + } + + return section; + }; +} + +} // 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..81faac3 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp @@ -0,0 +1,131 @@ +/* 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; + + g.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. + g.field_width = width < m.narrow_threshold ? m.value_field_width_narrow + : m.value_field_width; + + const int x0 = g.label_width + m.gap; + const int x1 = width - g.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.field = QRect(width - g.field_width, + (height - m.value_field_height) / 2, + g.field_width, + m.value_field_height); + + return g; +} + +void paint_slider_row(QPainter &painter, + const Theme &theme, + const SliderGeometry &geometry, + const SliderVisual &visual, + int height) +{ + 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(QRect(0, 0, geometry.label_width, height), + 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. + const int fill_w = geometry.thumb.center().x() - geometry.rail.x(); + if (fill_w > 0) + { + QRect fill = geometry.rail; + fill.setWidth(std::min(fill_w, geometry.rail.width())); + painter.setPen(Qt::NoPen); + painter.setBrush(theme.rail_fill(visual.category, visual.locked)); + painter.drawRoundedRect(QRectF(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); + + 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 From d64600e1d3de63ae84285d150ea409cc6f0d96ee Mon Sep 17 00:00:00 2001 From: Leonhardmaster2 Date: Tue, 1 Sep 2026 17:15:54 +0200 Subject: [PATCH 09/14] feat(qt): own the combo popup, animate sections, reserve the scrollbar Combo boxes now use a popup of our own rather than QComboBox. The stock popup composites a frame and an item view that are styled separately, so mid-open you see one surface and once it settles another, and the two rarely agree on colour. Painting the whole surface in one pass is the only way to make it read as a single thing. Covers EnumComboBox, ComboBox and ButtonGrid, roughly 6% of the rows. The four Qt popup traps are handled explicitly, since each looks like a different bug: it opens on release, because opening on press means the matching release lands outside and dismisses it; an outside press is closed by hand, because overriding mousePressEvent suppresses Qt's built-in dismissal; the close is timestamped in hideEvent rather than destroyed(), which fires an event-loop pass too late; and a click arriving within 200ms of a close is swallowed, otherwise clicking an open combo closes it and immediately reopens it so it never appears to close. Sections gain a real collapse animation. CollapsibleSection::set_expanded becomes virtual and its members protected so a design can animate without that class anticipating how. The animation drives setFixedHeight rather than maximumHeight, which Qt clamps upward so the body springs to full size for a frame; disables the body layout for the duration, since a live layout reads a shrinking parent as a squeeze and compresses the rows instead of clipping them; and measures the expanded height while the body is actually laid out, because sizeHint() on a hidden widget overestimates and the animation overshoots. Also: bind() is renamed bind_control(). An unqualified bind() call with a std type pulls std::bind in through ADL and fails deep inside , nowhere near the mistake. ui_font() probes for a neutral grotesque instead of taking whatever the platform hands out, falling through to the platform default when none is installed. --- .../meta_qt/designs/industrial/combo.hpp | 148 ++++++ .../designs/industrial/panel_chrome.hpp | 30 ++ .../meta_qt/designs/industrial/section.hpp | 43 +- MetaUI/qt/include/meta_qt/ui/binding.hpp | 7 +- .../qt/include/meta_qt/ui/design_registry.hpp | 2 +- .../meta_qt/widgets/collapsible_section.hpp | 10 +- MetaUI/qt/src/designs/industrial/combo.cpp | 456 ++++++++++++++++++ .../qt/src/designs/industrial/industrial.cpp | 8 + .../src/designs/industrial/panel_chrome.cpp | 50 ++ MetaUI/qt/src/designs/industrial/section.cpp | 184 +++++-- MetaUI/qt/src/ui/theme.cpp | 23 + MetaUI/qt/src/widgets/collapsible_section.cpp | 2 + 12 files changed, 905 insertions(+), 58 deletions(-) create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/panel_chrome.hpp create mode 100644 MetaUI/qt/src/designs/industrial/combo.cpp create mode 100644 MetaUI/qt/src/designs/industrial/panel_chrome.cpp 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..cb8fd1d --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp @@ -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. */ +#pragma once +#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; + + const Theme *theme_ = nullptr; + QStringList items_; + int current_ = -1; + int hovered_ = -1; +}; + +/// 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/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/section.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp index 0c3d4ef..0d9af5b 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp @@ -4,16 +4,49 @@ #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 Section factory matching the industrial rows. +/** @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. * - * Returns a stock CollapsibleSection wearing a theme-derived stylesheet rather - * than a subclass. The header is a QToolButton, and a stylesheet can restyle it - * completely without reaching into CollapsibleSection's internals, so the - * design gets its look without Meta having to widen that class's interface. + * Three things about animating a collapse in Qt, each of which looks like a + * different bug when got wrong: + * + * - Animate setFixedHeight, not maximumHeight. Qt clamps maximumHeight upward + * against minimumHeight, so the body springs to full size for a frame and + * then snaps away. + * - Disable the body's layout for the duration. A live layout reads a shrinking + * parent as a squeeze and redistributes the shortfall across every row, so + * the rows visibly compress instead of being clipped. + * - Measure the expanded height while the body is actually laid out. + * sizeHint() on a hidden, never-laid-out widget overestimates, and animating + * to it overshoots and snaps back. + */ +class Section : public CollapsibleSection +{ + Q_OBJECT + +public: + Section(const QString &title, const Theme &theme, QWidget *parent = nullptr); + + void set_expanded(bool new_state) override; + +private: + int measured_body_height() const; + + const Theme *theme_ = nullptr; + QVariantAnimation *animation_ = nullptr; + bool first_apply_ = true; +}; + +/** @brief Section factory matching the industrial rows. * * `theme` must outlive every section the factory builds. Pass one owned by the * ThemeRegistry. diff --git a/MetaUI/qt/include/meta_qt/ui/binding.hpp b/MetaUI/qt/include/meta_qt/ui/binding.hpp index 379443f..6e7484f 100644 --- a/MetaUI/qt/include/meta_qt/ui/binding.hpp +++ b/MetaUI/qt/include/meta_qt/ui/binding.hpp @@ -35,6 +35,10 @@ template <> struct ValueCompare }; /** @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: @@ -53,7 +57,8 @@ template <> struct ValueCompare * 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(Attribute &attr, Control &control, MetaWidget &host) +template +void bind_control(Attribute &attr, Control &control, MetaWidget &host) { const std::string key = attr.name(); diff --git a/MetaUI/qt/include/meta_qt/ui/design_registry.hpp b/MetaUI/qt/include/meta_qt/ui/design_registry.hpp index c6680c2..0a85572 100644 --- a/MetaUI/qt/include/meta_qt/ui/design_registry.hpp +++ b/MetaUI/qt/include/meta_qt/ui/design_registry.hpp @@ -54,7 +54,7 @@ template RowFactory make_row_factory() auto *control = new ControlT(attr, ctx, host); host->layout()->addWidget(control); - bind(attr, *control, *host); + bind_control(attr, *control, *host); return host; }; 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/src/designs/industrial/combo.cpp b/MetaUI/qt/src/designs/industrial/combo.cpp new file mode 100644 index 0000000..af7b6d8 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/combo.cpp @@ -0,0 +1,456 @@ +/* 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 + +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) +{ + 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); + const int 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() + height <= screen.bottom(); + + // A flipped popup already sits at its final top edge, so growing downward + // would make it appear to fall from the ceiling rather than open out of the + // control. Pin the bottom edge instead. + const int y = fits_below ? field_global.bottom() + 2 + : field_global.top() - height - 2; + + setGeometry(field_global.left(), y, width, height); + show(); + setFocus(Qt::PopupFocusReason); +} + +int ComboPopup::index_at(const QPoint &pos) const +{ + if (!rect().adjusted(0, kPopupPadding, 0, -kPopupPadding).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_; + + // 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(rect()).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(rect())) 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 index 0d853a5..cdbc67c 100644 --- a/MetaUI/qt/src/designs/industrial/industrial.cpp +++ b/MetaUI/qt/src/designs/industrial/industrial.cpp @@ -4,6 +4,7 @@ #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/stock/stock.hpp" @@ -33,6 +34,13 @@ void register_design() // 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. 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/section.cpp b/MetaUI/qt/src/designs/industrial/section.cpp index 79228b1..986a797 100644 --- a/MetaUI/qt/src/designs/industrial/section.cpp +++ b/MetaUI/qt/src/designs/industrial/section.cpp @@ -3,63 +3,149 @@ this software. */ #include "meta_qt/designs/industrial/section.hpp" -#include "meta_qt/widgets/collapsible_section.hpp" +#include +#include +#include namespace meta::qt::industrial { -SectionFactory make_section_factory(const Theme &theme) +namespace { - return [&theme](const QString &title) -> CollapsibleSection * - { - auto *section = new CollapsibleSection(title); +QString header_stylesheet(const Theme &theme) +{ + const Metrics &m = theme.metrics; + + // The header is the inherited QToolButton. Styling it by type selector keeps + // this independent of how CollapsibleSection lays itself out. + // + // 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. + return QString("QToolButton {" + " background: %1;" + " color: %2;" + " border: none;" + " border-top: 1px solid %3;" + " border-bottom: 1px solid %4;" + " padding: 0px 12px;" + " min-height: %5px;" + " text-align: left;" + " font-weight: bold;" + " letter-spacing: 1px;" + "}" + "QToolButton:hover { background: %6; }" + "QToolButton:pressed { background: %7; }" + "QToolButton:checked { background: %1; color: %2; }") + .arg(theme.section_header.name()) + .arg(theme.ink_section_title.name()) + .arg(theme.bevel_top.name()) + .arg(theme.bevel_bottom.name()) + .arg(m.section_header_height) + .arg(theme.section_header_hover.name()) + .arg(theme.section_header_press.name()); +} + +} // namespace + +Section::Section(const QString &title, const Theme &theme, QWidget *parent) + : CollapsibleSection(title, parent), theme_(&theme) +{ + setStyleSheet(header_stylesheet(theme)); + + 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); + } + + animation_ = new QVariantAnimation(this); + animation_->setDuration(theme.metrics.section_ms); + animation_->setEasingCurve(QEasingCurve::OutCubic); + + connect(animation_, + &QVariantAnimation::valueChanged, + this, + [this](const QVariant &v) { content->setFixedHeight(v.toInt()); }); + + connect(animation_, + &QVariantAnimation::finished, + this, + [this]() + { + // Hand the body back to the layout system once it has settled, + // otherwise it stays pinned at the animated height and stops + // responding to content changes. + if (content->layout()) content->layout()->setEnabled(true); + + if (is_expanded()) + { + content->setMinimumHeight(0); + content->setMaximumHeight(QWIDGETSIZE_MAX); + } + else + { + content->setVisible(false); + } + }); +} + +int Section::measured_body_height() const +{ + // Measure the real laid-out height where possible. sizeHint() on a hidden, + // never-laid-out widget overestimates, and animating to it overshoots. + if (content->isVisible() && content->height() > 0) return content->height(); + return content->sizeHint().height(); +} + +void Section::set_expanded(bool new_state) +{ + const bool was_expanded = is_expanded(); + + toggle_button->setArrowType(new_state ? Qt::DownArrow : Qt::RightArrow); + { + QSignalBlocker blocker(toggle_button); + toggle_button->setChecked(new_state); + } + + // The first call comes from restoring persisted state during construction, + // before anything is on screen. Animating that would play every section open + // on startup, so seat it directly. + if (first_apply_ || was_expanded == new_state) + { + first_apply_ = false; + content->setVisible(new_state); + Q_EMIT expanded_state_changed(new_state); + return; + } + + const int target = new_state ? measured_body_height() : 0; + const int start = new_state ? 0 : measured_body_height(); - // The header is the section's QToolButton. Styling it by type selector - // avoids depending on any private member of CollapsibleSection. - // - // 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. - const QString style = - QString("QToolButton {" - " background: %1;" - " color: %2;" - " border: none;" - " border-top: 1px solid %3;" - " border-bottom: 1px solid %4;" - " border-radius: %5px;" - " padding: 0px 8px;" - " min-height: %6px;" - " text-align: left;" - " font-weight: bold;" - "}" - "QToolButton:hover { background: %7; }" - "QToolButton:pressed { background: %8; }" - "QToolButton:checked { background: %1; color: %2; }") - .arg(theme.section_header.name()) - .arg(theme.ink_section_title.name()) - .arg(theme.bevel_top.name()) - .arg(theme.bevel_bottom.name()) - .arg(m.radius) - .arg(m.section_header_height) - .arg(theme.section_header_hover.name()) - .arg(theme.section_header_press.name()); - - section->setStyleSheet(style); - - if (section->content_layout) - { - section->content_layout->setContentsMargins(m.section_body_padding_x, - m.section_body_padding_y, - m.section_body_padding_x, - m.section_body_padding_y); - section->content_layout->setSpacing(m.section_row_spacing); - } - - return section; - }; + // A live layout treats a shrinking parent as a squeeze and redistributes the + // shortfall across the rows, so they compress instead of being clipped. + if (content->layout()) content->layout()->setEnabled(false); + + content->setVisible(true); + content->setFixedHeight(start); + + animation_->stop(); // a running animation ignores a retargeted end value + animation_->setStartValue(start); + animation_->setEndValue(target); + animation_->start(); + + 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/ui/theme.cpp b/MetaUI/qt/src/ui/theme.cpp index 8acbab4..94dc463 100644 --- a/MetaUI/qt/src/ui/theme.cpp +++ b/MetaUI/qt/src/ui/theme.cpp @@ -41,7 +41,30 @@ QFont mono_font(int pixel_size) 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) 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); From 4fd032a94b48dfbf77ab53ddc1c093fa165cf5ea Mon Sep 17 00:00:00 2001 From: Leonhardmaster2 Date: Tue, 1 Sep 2026 21:50:06 +0200 Subject: [PATCH 10/14] feat(qt): section cards, popup reveal and a square points canvas Sections are drawn as a card: the header and the body each paint the same background, with the section painting one underneath to fill the few pixels between them. All three name the colour explicitly. Nothing else works here. Painting only the card is invisible, because both children are opaque and cover it -- a magenta test card showed through as a three-pixel line and nothing more. Making the header "transparent" so the card shows is equally unreliable: Qt falls back to painting a QToolButton itself for any state a stylesheet does not name, and :checked is every expanded section, so the header came out a different shade no matter what the card was set to. Collapsing moves a ClipBox's reveal rather than resizing anything. The body is kept at its natural size and cropped, because a child is clipped to its parent for free. Constraining the body instead fails three different ways: a fixed height has to land exactly on the final size hint or it snaps when released, a maximumHeight is clamped back up by the body's own Fixed policy, and leaving the body Preferred lets the parent layout redistribute space across every section, so one animating section nudges all of them -- worse the further down the column they sit, because the error accumulates. The clip box also watches the body for LayoutRequest. A widget whose height follows its width settles after first layout, and without this it stays cropped at whatever it measured first. Combo popups reveal by clipping inside a fixed translucent window rather than resizing the window itself: the platform enforces a minimum window size and coalesces rapid resizes, so an animated geometry simply snaps. WA_TranslucentBackground has to be set before the native window exists, and without WA_NoSystemBackground beside it, or the surface is left undefined rather than clear. PointsCanvas derives its height from its width instead of a hardcoded 220. The point domain is square, so a fixed height only matched it at one panel width. This changes the stock widget for every consumer, not only this design. --- .../meta_qt/designs/industrial/combo.hpp | 22 +- .../meta_qt/designs/industrial/section.hpp | 79 ++++- .../designs/industrial/slider_chrome.hpp | 13 +- MetaUI/qt/include/meta_qt/ui/theme.hpp | 11 +- .../include/meta_qt/widgets/points_canvas.hpp | 4 + MetaUI/qt/src/designs/industrial/combo.cpp | 70 ++++- .../qt/src/designs/industrial/int_slider.cpp | 2 +- .../src/designs/industrial/param_slider.cpp | 8 +- MetaUI/qt/src/designs/industrial/section.cpp | 271 ++++++++++++++---- .../src/designs/industrial/slider_chrome.cpp | 39 +-- MetaUI/qt/src/ui/theme.cpp | 13 +- MetaUI/qt/src/widgets/points_canvas.cpp | 14 +- 12 files changed, 424 insertions(+), 122 deletions(-) diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp index cb8fd1d..a4d8d43 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -60,13 +61,20 @@ class ComboPopup : public QWidget void hideEvent(QHideEvent *event) override; private: - int index_at(const QPoint &pos) const; - int row_height() const; - - const Theme *theme_ = nullptr; - QStringList items_; - int current_ = -1; - int hovered_ = -1; + 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. diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp index 0d9af5b..171cd70 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp @@ -11,23 +11,64 @@ 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. * - * Three things about animating a collapse in Qt, each of which looks like a - * different bug when got wrong: + * 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. * - * - Animate setFixedHeight, not maximumHeight. Qt clamps maximumHeight upward - * against minimumHeight, so the body springs to full size for a frame and - * then snaps away. - * - Disable the body's layout for the duration. A live layout reads a shrinking - * parent as a squeeze and redistributes the shortfall across every row, so - * the rows visibly compress instead of being clipped. - * - Measure the expanded height while the body is actually laid out. - * sizeHint() on a hidden, never-laid-out widget overestimates, and animating - * to it overshoots and snaps back. + * Cropping sidesteps all of it. The body is never resized, so there is no + * negotiation to get wrong. */ class Section : public CollapsibleSection { @@ -38,12 +79,24 @@ class Section : public CollapsibleSection void set_expanded(bool new_state) override; -private: - int measured_body_height() const; +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. diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp index c89f61d..be49c71 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp @@ -2,6 +2,7 @@ Public License. The full license is in the file LICENSE, distributed with this software. */ #pragma once +#include #include #include @@ -19,11 +20,11 @@ namespace meta::qt::industrial */ struct SliderGeometry { - int label_width = 0; - int field_width = 0; - QRect rail; - QRect thumb; - QRect field; + 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); }; @@ -32,7 +33,7 @@ struct SliderGeometry struct SliderVisual { QString label; - std::string category; ///< selects the group accent for the rail fill + std::string category; ///< selects the group accent for the rail fill bool modified = false; bool locked = false; }; diff --git a/MetaUI/qt/include/meta_qt/ui/theme.hpp b/MetaUI/qt/include/meta_qt/ui/theme.hpp index 68b8a45..ca658f3 100644 --- a/MetaUI/qt/include/meta_qt/ui/theme.hpp +++ b/MetaUI/qt/include/meta_qt/ui/theme.hpp @@ -64,6 +64,10 @@ struct Metrics 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; @@ -113,9 +117,10 @@ struct Theme // --- surfaces QColor page{"#2b2b2b"}; QColor bar{"#262626"}; - QColor section_header{"#333333"}; - QColor section_header_hover{"#383838"}; - QColor section_header_press{"#303030"}; + 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"}; 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/designs/industrial/combo.cpp b/MetaUI/qt/src/designs/industrial/combo.cpp index af7b6d8..c96fccb 100644 --- a/MetaUI/qt/src/designs/industrial/combo.cpp +++ b/MetaUI/qt/src/designs/industrial/combo.cpp @@ -11,7 +11,9 @@ #include #include #include +#include #include +#include namespace meta::qt::industrial { @@ -37,6 +39,14 @@ ComboPopup::ComboPopup(const Theme &theme, : 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); @@ -52,27 +62,59 @@ bool ComboPopup::should_swallow_reopen() void ComboPopup::popup_for(const QRect &field_global) { const int visible = std::min(items_.size(), kMaxVisibleRows); - const int height = visible * row_height() + 2 * kPopupPadding; + 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 bool fits_below = field_global.bottom() + height <= screen.bottom(); + const int left = field_global.left(); + flipped_ = !fits_below; - // A flipped popup already sits at its final top edge, so growing downward - // would make it appear to fall from the ceiling rather than open out of the - // control. Pin the bottom edge instead. const int y = fits_below ? field_global.bottom() + 2 - : field_global.top() - height - 2; - - setGeometry(field_global.left(), y, width, height); + : 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().adjusted(0, kPopupPadding, 0, -kPopupPadding).contains(pos)) return -1; + if (!rect().contains(pos)) return -1; const int index = (pos.y() - kPopupPadding) / row_height(); return index >= 0 && index < items_.size() ? index : -1; @@ -84,12 +126,18 @@ void ComboPopup::paintEvent(QPaintEvent *) 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(rect()).adjusted(0.5, 0.5, -0.5, -0.5), + painter.drawRoundedRect(QRectF(card).adjusted(0.5, 0.5, -0.5, -0.5), t.metrics.radius, t.metrics.radius); @@ -98,7 +146,7 @@ void ComboPopup::paintEvent(QPaintEvent *) for (int i = 0; i < items_.size(); ++i) { const QRect row(1, kPopupPadding + i * row_height(), width() - 2, row_height()); - if (!row.intersects(rect())) continue; + if (!row.intersects(card)) continue; if (i == hovered_) { diff --git a/MetaUI/qt/src/designs/industrial/int_slider.cpp b/MetaUI/qt/src/designs/industrial/int_slider.cpp index 01646ee..0292d37 100644 --- a/MetaUI/qt/src/designs/industrial/int_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/int_slider.cpp @@ -138,7 +138,7 @@ void IntSlider::paintEvent(QPaintEvent *) label_font.setCapitalization(QFont::AllUppercase); visual.label = elide_label(QString::fromStdString(label_), label_font, - geometry.label_width); + geometry.label.width()); paint_slider_row(painter, theme(), geometry, visual, height()); } diff --git a/MetaUI/qt/src/designs/industrial/param_slider.cpp b/MetaUI/qt/src/designs/industrial/param_slider.cpp index 08986e5..c160f72 100644 --- a/MetaUI/qt/src/designs/industrial/param_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/param_slider.cpp @@ -180,7 +180,7 @@ void ParamSlider::paintEvent(QPaintEvent *) label_font.setCapitalization(QFont::AllUppercase); visual.label = elide_label(QString::fromStdString(label_), label_font, - geometry.label_width); + geometry.label.width()); paint_slider_row(painter, theme(), geometry, visual, height()); } @@ -305,10 +305,10 @@ void ParamSlider::set_from_position(int x) { const Metrics &m = theme().metrics; const SliderGeometry g = SliderGeometry::compute(theme(), width(), height(), norm_); - const QRect rail = g.rail; - const int travel = std::max(1, rail.width() - m.thumb_width); + const int travel = std::max(1, g.rail.width() - m.thumb_width); - apply_norm(std::clamp(qreal(x - rail.x() - m.thumb_width / 2) / travel, 0.0, 1.0)); + apply_norm( + std::clamp(qreal(x - g.rail.x() - m.thumb_width / 2) / travel, 0.0, 1.0)); } void ParamSlider::apply_norm(qreal t) diff --git a/MetaUI/qt/src/designs/industrial/section.cpp b/MetaUI/qt/src/designs/industrial/section.cpp index 986a797..a1c4da2 100644 --- a/MetaUI/qt/src/designs/industrial/section.cpp +++ b/MetaUI/qt/src/designs/industrial/section.cpp @@ -3,9 +3,16 @@ this software. */ #include "meta_qt/designs/industrial/section.hpp" +#include + #include +#include +#include #include #include +#include + +#include "meta/logger.hpp" namespace meta::qt::industrial { @@ -13,46 +20,172 @@ 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; - // The header is the inherited QToolButton. Styling it by type selector keeps - // this independent of how CollapsibleSection lays itself out. + // 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. // - // 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. - return QString("QToolButton {" - " background: %1;" - " color: %2;" + // 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;" - " border-top: 1px solid %3;" - " border-bottom: 1px solid %4;" + " outline: none;" + " color: %2;" " padding: 0px 12px;" - " min-height: %5px;" + " min-height: %3px;" " text-align: left;" " font-weight: bold;" - " letter-spacing: 1px;" "}" - "QToolButton:hover { background: %6; }" - "QToolButton:pressed { background: %7; }" - "QToolButton:checked { background: %1; color: %2; }") + "QToolButton:hover { background-color: %4; color: %5; }") .arg(theme.section_header.name()) .arg(theme.ink_section_title.name()) - .arg(theme.bevel_top.name()) - .arg(theme.bevel_bottom.name()) .arg(m.section_header_height) .arg(theme.section_header_hover.name()) - .arg(theme.section_header_press.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) { - setStyleSheet(header_stylesheet(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) { @@ -64,6 +197,28 @@ Section::Section(const QString &title, const Theme &theme, QWidget *parent) 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); @@ -71,41 +226,42 @@ Section::Section(const QString &title, const Theme &theme, QWidget *parent) connect(animation_, &QVariantAnimation::valueChanged, this, - [this](const QVariant &v) { content->setFixedHeight(v.toInt()); }); + [this](const QVariant &v) { clip_->set_reveal(v.toInt()); }); connect(animation_, &QVariantAnimation::finished, this, [this]() { - // Hand the body back to the layout system once it has settled, - // otherwise it stays pinned at the animated height and stops - // responding to content changes. - if (content->layout()) content->layout()->setEnabled(true); - - if (is_expanded()) - { - content->setMinimumHeight(0); - content->setMaximumHeight(QWIDGETSIZE_MAX); - } - else - { - content->setVisible(false); - } + // 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(); }); } -int Section::measured_body_height() const +void Section::paintEvent(QPaintEvent *) { - // Measure the real laid-out height where possible. sizeHint() on a hidden, - // never-laid-out widget overestimates, and animating to it overshoots. - if (content->isVisible() && content->height() > 0) return content->height(); - return content->sizeHint().height(); + 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 = is_expanded(); + const bool was_expanded = expanded_; + expanded_ = new_state; toggle_button->setArrowType(new_state ? Qt::DownArrow : Qt::RightArrow); { @@ -113,32 +269,39 @@ void Section::set_expanded(bool new_state) toggle_button->setChecked(new_state); } - // The first call comes from restoring persisted state during construction, - // before anything is on screen. Animating that would play every section open - // on startup, so seat it directly. - if (first_apply_ || was_expanded == new_state) + if (!clip_) { - first_apply_ = false; content->setVisible(new_state); Q_EMIT expanded_state_changed(new_state); return; } - const int target = new_state ? measured_body_height() : 0; - const int start = new_state ? 0 : measured_body_height(); + // 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); - // A live layout treats a shrinking parent as a squeeze and redistributes the - // shortfall across the rows, so they compress instead of being clipped. - if (content->layout()) content->layout()->setEnabled(false); + update(); + Q_EMIT expanded_state_changed(new_state); + return; + } - content->setVisible(true); - content->setFixedHeight(start); + const int full = clip_->body_height(); animation_->stop(); // a running animation ignores a retargeted end value - animation_->setStartValue(start); - animation_->setEndValue(target); + animation_->setStartValue(new_state ? 0 : full); + animation_->setEndValue(new_state ? full : 0); animation_->start(); + update(); Q_EMIT expanded_state_changed(new_state); } diff --git a/MetaUI/qt/src/designs/industrial/slider_chrome.cpp b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp index 81faac3..09a02b7 100644 --- a/MetaUI/qt/src/designs/industrial/slider_chrome.cpp +++ b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp @@ -21,16 +21,18 @@ SliderGeometry SliderGeometry::compute(const Theme &theme, SliderGeometry g; - g.label_width = int(std::clamp(width * m.label_width_ratio, - m.label_min_width, - m.label_max_width)); + 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. - g.field_width = width < m.narrow_threshold ? m.value_field_width_narrow - : m.value_field_width; + const int field_width = width < m.narrow_threshold ? m.value_field_width_narrow + : m.value_field_width; - const int x0 = g.label_width + m.gap; - const int x1 = width - g.field_width - m.gap; + 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); @@ -40,9 +42,14 @@ SliderGeometry SliderGeometry::compute(const Theme &theme, m.thumb_width, m.thumb_height); - g.field = QRect(width - g.field_width, + 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, - g.field_width, + field_width, m.value_field_height); return g; @@ -52,7 +59,7 @@ void paint_slider_row(QPainter &painter, const Theme &theme, const SliderGeometry &geometry, const SliderVisual &visual, - int height) + int) { painter.setRenderHint(QPainter::Antialiasing, true); @@ -63,9 +70,7 @@ void paint_slider_row(QPainter &painter, label_font.setCapitalization(QFont::AllUppercase); painter.setFont(label_font); painter.setPen(theme.state_ink(visual.modified, visual.locked)); - painter.drawText(QRect(0, 0, geometry.label_width, height), - Qt::AlignLeft | Qt::AlignVCenter, - visual.label); + painter.drawText(geometry.label, Qt::AlignLeft | Qt::AlignVCenter, visual.label); if (geometry.rail.width() <= 0) return; @@ -77,14 +82,11 @@ void paint_slider_row(QPainter &painter, m.rail_radius); // --- fill. Always the group accent; never a state colour. - const int fill_w = geometry.thumb.center().x() - geometry.rail.x(); - if (fill_w > 0) + if (geometry.fill.width() > 0) { - QRect fill = geometry.rail; - fill.setWidth(std::min(fill_w, geometry.rail.width())); painter.setPen(Qt::NoPen); painter.setBrush(theme.rail_fill(visual.category, visual.locked)); - painter.drawRoundedRect(QRectF(fill).adjusted(0.5, 0.5, -0.5, -0.5), + painter.drawRoundedRect(QRectF(geometry.fill).adjusted(0.5, 0.5, -0.5, -0.5), m.rail_radius, m.rail_radius); } @@ -102,6 +104,7 @@ void paint_slider_row(QPainter &painter, m.radius, m.radius); + // grip notch, 2x8 centred painter.setPen(Qt::NoPen); painter.setBrush(theme.thumb_grip); painter.drawRect( diff --git a/MetaUI/qt/src/ui/theme.cpp b/MetaUI/qt/src/ui/theme.cpp index 94dc463..0f502f3 100644 --- a/MetaUI/qt/src/ui/theme.cpp +++ b/MetaUI/qt/src/ui/theme.cpp @@ -107,9 +107,16 @@ Theme Theme::from_palette(const QPalette &palette, const std::string &name) // --- surfaces t.page = window; t.bar = sink(window, 0.06); - t.section_header = lift(window, 0.06); - t.section_header_hover = lift(window, 0.10); - t.section_header_press = lift(window, 0.03); + // 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); diff --git a/MetaUI/qt/src/widgets/points_canvas.cpp b/MetaUI/qt/src/widgets/points_canvas.cpp index 453d718..3882884 100644 --- a/MetaUI/qt/src/widgets/points_canvas.cpp +++ b/MetaUI/qt/src/widgets/points_canvas.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -38,13 +39,22 @@ 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); From 82979854c5d7334906dbfa8932bf0c9775b9704e Mon Sep 17 00:00:00 2001 From: Otto Link Date: Thu, 3 Sep 2026 11:24:59 +0200 Subject: [PATCH 11/14] fix(qt): preserve color stop positions when editing or moving a stop (#48) --- MetaUI/qt/src/widgets/gradient_picker.cpp | 42 +++++++++++++++-------- 1 file changed, 28 insertions(+), 14 deletions(-) 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; From e18c184cec502dbd3c78b21354ecb092088c8a6b Mon Sep 17 00:00:00 2001 From: Otto Link Date: Thu, 3 Sep 2026 16:36:11 +0200 Subject: [PATCH 12/14] feat(qt): unify DesignRegistry as full design bundle with container integration --- .../qt/include/meta_qt/container_widget.hpp | 21 +- .../meta_qt/designs/industrial/check_row.hpp | 4 +- .../meta_qt/designs/industrial/combo.hpp | 22 +- .../meta_qt/designs/industrial/int_slider.hpp | 4 +- .../designs/industrial/param_slider.hpp | 12 +- .../meta_qt/designs/industrial/section.hpp | 1 - .../designs/industrial/slider_chrome.hpp | 10 +- .../meta_qt/designs/stock/stock_renderer.hpp | 39 ++++ MetaUI/qt/include/meta_qt/ui/binding.hpp | 10 +- .../qt/include/meta_qt/ui/design_registry.hpp | 47 +++- MetaUI/qt/include/meta_qt/ui/glide.hpp | 3 +- MetaUI/qt/include/meta_qt/ui/theme.hpp | 47 ++-- MetaUI/qt/include/meta_qt/widget_renderer.hpp | 65 +----- .../meta_qt/widget_renderer_inl/array.inl | 23 +- .../meta_qt/widget_renderer_inl/bool.inl | 7 +- .../widget_renderer_inl/color_gradient.inl | 9 +- .../meta_qt/widget_renderer_inl/float.inl | 7 +- .../meta_qt/widget_renderer_inl/glm_ivec2.inl | 7 +- .../meta_qt/widget_renderer_inl/glm_vec2.inl | 7 +- .../meta_qt/widget_renderer_inl/glm_vec3.inl | 8 +- .../meta_qt/widget_renderer_inl/glm_vec4.inl | 8 +- .../meta_qt/widget_renderer_inl/int.inl | 7 +- .../std_filesystem_path.inl | 8 +- .../widget_renderer_inl/std_string.inl | 19 +- .../widget_renderer_inl/std_vector_float.inl | 8 +- .../std_vector_glm_vec3.inl | 8 +- .../src/container_widget/container_widget.cpp | 46 +++- .../qt/src/designs/industrial/check_row.cpp | 16 +- MetaUI/qt/src/designs/industrial/combo.cpp | 43 ++-- .../qt/src/designs/industrial/industrial.cpp | 16 +- .../qt/src/designs/industrial/int_slider.cpp | 26 ++- .../src/designs/industrial/param_slider.cpp | 53 +++-- MetaUI/qt/src/designs/industrial/section.cpp | 2 +- .../src/designs/industrial/slider_chrome.cpp | 44 ++-- MetaUI/qt/src/designs/stock/stock.cpp | 119 +++++++--- MetaUI/qt/src/ui/control.cpp | 7 +- MetaUI/qt/src/ui/design_registry.cpp | 113 ++++++++-- MetaUI/qt/src/ui/theme.cpp | 27 ++- MetaUI/qt/src/widget_renderer.cpp | 96 +------- MetaUI/qt/src/widgets/points_canvas.cpp | 5 +- tests/test_qt/test_meta_qt/main.cpp | 65 ++++-- tests/unittests/CMakeLists.txt | 26 ++- tests/unittests/main.cpp | 11 + tests/unittests/test_design_registry.cpp | 209 ++++++++++++++++++ 44 files changed, 925 insertions(+), 410 deletions(-) create mode 100644 MetaUI/qt/include/meta_qt/designs/stock/stock_renderer.hpp create mode 100644 tests/unittests/test_design_registry.cpp diff --git a/MetaUI/qt/include/meta_qt/container_widget.hpp b/MetaUI/qt/include/meta_qt/container_widget.hpp index f4d2ab0..803a59a 100644 --- a/MetaUI/qt/include/meta_qt/container_widget.hpp +++ b/MetaUI/qt/include/meta_qt/container_widget.hpp @@ -11,6 +11,7 @@ #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 @@ -34,33 +35,33 @@ enum GroupSwitchMode /** @brief Builds the widget for a single attribute. * - * The indirection that lets a host supply an alternative widget design without - * the container layer knowing any design exists. Leave unset for the stock - * renderer; see meta_qt/ui/design_registry.hpp for the registry-backed one. + * 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. * - * Same indirection as AttributeRowRenderer, for the chrome around the rows - * rather than the rows themselves. A section is not bound to an attribute, so - * it cannot go through the design registry; it is supplied here instead. - * Leave unset for the stock section. + * Optional override for category section chrome. If empty, the SectionFactory + * associated with options.design in DesignRegistry is used. */ -using SectionFactory = std::function; +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 = {}; ///< Per-attribute widget builder; empty = stock - SectionFactory section_factory = {}; ///< Category section builder; empty = stock + AttributeRowRenderer row_renderer = {}; ///< Optional custom row builder override + SectionFactory section_factory = {}; ///< Optional custom section builder override // clang-format on }; diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/check_row.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/check_row.hpp index 202fcd1..76221e5 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/check_row.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/check_row.hpp @@ -25,7 +25,9 @@ class CheckRow : public Control Q_OBJECT public: - CheckRow(Attribute &attr, const RowContext &ctx, QWidget *parent = nullptr); + 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; } diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp index a4d8d43..b1291fa 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp @@ -41,12 +41,16 @@ class ComboPopup : public QWidget Q_OBJECT public: - ComboPopup(const Theme &theme, const QStringList &items, int current, QWidget *parent); + 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. + /// True while a click should be ignored because it is the reopen half of a + /// close. static bool should_swallow_reopen(); signals: @@ -61,8 +65,8 @@ class ComboPopup : public QWidget void hideEvent(QHideEvent *event) override; private: - int index_at(const QPoint &pos) const; - int row_height() const; + 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; @@ -95,7 +99,9 @@ class EnumCombo : public Control Q_OBJECT public: - EnumCombo(Attribute &attr, const RowContext &ctx, QWidget *parent = nullptr); + EnumCombo(Attribute &attr, + const RowContext &ctx, + QWidget *parent = nullptr); /// Needs enum_items to have anything to show. static bool can_render(const Attribute &attr); @@ -113,10 +119,10 @@ class EnumCombo : public Control private: void open_popup(); - int value_ = 0; + int value_ = 0; std::vector> items_; - std::string label_; - bool open_ = false; + std::string label_; + bool open_ = false; }; /** @brief Dropdown for a string attribute carrying allowed_values. diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp index a888aab..57621d4 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp @@ -31,7 +31,9 @@ class IntSlider : public Control Q_OBJECT public: - IntSlider(Attribute &attr, const RowContext &ctx, QWidget *parent = nullptr); + 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); diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp index b19204d..c1f221f 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp @@ -29,7 +29,9 @@ class ParamSlider : public Control Q_OBJECT public: - ParamSlider(Attribute &attr, const RowContext &ctx, QWidget *parent = nullptr); + ParamSlider(Attribute &attr, + const RowContext &ctx, + QWidget *parent = nullptr); /** @brief Decline attributes with no usable range. * @@ -62,11 +64,11 @@ class ParamSlider : public Control qreal to_norm(float value) const; float from_norm(qreal t) const; - void set_from_position(int x); - void apply_norm(qreal t); + 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); + void refresh_field(); + void restyle_field(bool editing = false); float min_ = 0.f; float max_ = 1.f; diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp index 171cd70..0590e12 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp @@ -83,7 +83,6 @@ class Section : public CollapsibleSection /// Draws the single card the header and rows sit on. void paintEvent(QPaintEvent *event) override; - private: const Theme *theme_ = nullptr; ClipBox *clip_ = nullptr; diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp index be49c71..2c52c12 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp @@ -26,7 +26,10 @@ struct SliderGeometry QRect thumb; ///< machined handle QRect field; ///< value readout, right - static SliderGeometry compute(const Theme &theme, int width, int height, qreal norm); + 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. @@ -53,6 +56,9 @@ void paint_slider_row(QPainter &painter, 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); +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_renderer.hpp b/MetaUI/qt/include/meta_qt/designs/stock/stock_renderer.hpp new file mode 100644 index 0000000..9120ce9 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/stock/stock_renderer.hpp @@ -0,0 +1,39 @@ +/* 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/core/attribute.hpp" +#include "meta/type/type_name.hpp" +#include "meta_common.hpp" +#include "meta_qt/meta_widget.hpp" + +namespace meta::qt::stock +{ + +/// Fallback renderer for unsupported stock types. +template struct StockRenderer +{ + 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; + } +}; + +} // namespace meta::qt::stock diff --git a/MetaUI/qt/include/meta_qt/ui/binding.hpp b/MetaUI/qt/include/meta_qt/ui/binding.hpp index 6e7484f..c72ea66 100644 --- a/MetaUI/qt/include/meta_qt/ui/binding.hpp +++ b/MetaUI/qt/include/meta_qt/ui/binding.hpp @@ -81,7 +81,8 @@ void bind_control(Attribute &attr, Control &control, MetaWidget &host) try { - control.set_modified(!ValueCompare::equal(attr.value(), std::any_cast(def))); + control.set_modified( + !ValueCompare::equal(attr.value(), std::any_cast(def))); } catch (const std::bad_any_cast &) { @@ -128,11 +129,12 @@ void bind_control(Attribute &attr, Control &control, MetaWidget &host) }); // 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(); }); + 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)); + control.set_locked( + meta::common::try_get(attr, meta::keys::ui::read_only, false)); refresh_modified(); } diff --git a/MetaUI/qt/include/meta_qt/ui/design_registry.hpp b/MetaUI/qt/include/meta_qt/ui/design_registry.hpp index 0a85572..2eb17d3 100644 --- a/MetaUI/qt/include/meta_qt/ui/design_registry.hpp +++ b/MetaUI/qt/include/meta_qt/ui/design_registry.hpp @@ -15,10 +15,16 @@ #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 *)>; @@ -26,12 +32,13 @@ using RowFactory = std::function< /// 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`. +/** @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(). + * `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 @@ -104,9 +111,13 @@ class DesignRegistry /// Convenience wrapper around add() + make_row_factory(). template - void register_control(const std::string &design, const std::string &widget_type) + void register_control(const std::string &design, + const std::string &widget_type) { - add(design, std::type_index(typeid(T)), widget_type, make_row_factory()); + add(design, + std::type_index(typeid(T)), + widget_type, + make_row_factory()); } /** @brief Build a row for `p_attr` using `design`, following its fallbacks. @@ -120,6 +131,28 @@ class DesignRegistry 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. @@ -132,6 +165,8 @@ class DesignRegistry std::map> factories_; std::map fallbacks_; + std::map section_factories_; + std::map themes_; }; /** @brief Render one attribute using `design`. diff --git a/MetaUI/qt/include/meta_qt/ui/glide.hpp b/MetaUI/qt/include/meta_qt/ui/glide.hpp index c23e96b..c692e2f 100644 --- a/MetaUI/qt/include/meta_qt/ui/glide.hpp +++ b/MetaUI/qt/include/meta_qt/ui/glide.hpp @@ -30,7 +30,8 @@ class Glide : public QObject /// Animate towards `target`. Safe to call while already running. void to(qreal target); - /// Move immediately, cancelling any running animation. Emits tick(), not finished(). + /// Move immediately, cancelling any running animation. Emits tick(), not + /// finished(). void jump(qreal value); qreal current() const { return current_; } diff --git a/MetaUI/qt/include/meta_qt/ui/theme.hpp b/MetaUI/qt/include/meta_qt/ui/theme.hpp index ca658f3..f5fbd61 100644 --- a/MetaUI/qt/include/meta_qt/ui/theme.hpp +++ b/MetaUI/qt/include/meta_qt/ui/theme.hpp @@ -38,7 +38,8 @@ struct Metrics 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 + int narrow_threshold = + 430; ///< row width below which the narrow branch applies // --- value field int value_field_width = 74; @@ -64,10 +65,10 @@ struct Metrics 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_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 + int row_bar_height = 30; ///< the bar a value row is drawn inside // --- shared int radius = 2; @@ -83,13 +84,14 @@ struct Metrics * 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. + * 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. + * 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 { @@ -112,13 +114,15 @@ struct Theme * * Geometry (Metrics) is untouched: it is not a palette concern. */ - static Theme from_palette(const QPalette &palette, const std::string &name = "palette"); + 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_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"}; @@ -157,19 +161,21 @@ struct Theme // --- accent QColor accent{"#e08a2e"}; - /** @brief Per-group accents, keyed by attribute category. Falls back to `accent`. + /** @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")}}; + 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; @@ -206,7 +212,8 @@ struct Theme * 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). + * afterwards, so a palette change mid-session is not picked up (again: + * restart). */ class ThemeRegistry { 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 index 16bb762..78c0859 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/array.inl +++ b/MetaUI/qt/include/meta_qt/widget_renderer_inl/array.inl @@ -3,21 +3,18 @@ this software. */ #pragma once +#include "meta/ext/array/array.hpp" +#include "meta_common.hpp" +#include "meta_qt/designs/stock/stock_renderer.hpp" +#include "meta_qt/meta_widget.hpp" +#include "meta_qt/widgets/array_canvas.hpp" +#include "meta_qt/widgets/points_canvas.hpp" #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 +namespace meta::qt::stock { // ===================================== @@ -126,10 +123,10 @@ inline std::vector resample_bicubic_array(const std::vector &src, } // --------------------------------------------------------------------------- -// WidgetRenderer +// StockRenderer // --------------------------------------------------------------------------- -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) { @@ -279,4 +276,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/bool.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/bool.inl index 46a44ef..c4240b0 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/bool.inl +++ b/MetaUI/qt/include/meta_qt/widget_renderer_inl/bool.inl @@ -11,12 +11,13 @@ #include "meta/type/type_name.hpp" #include "meta_common.hpp" +#include "meta_qt/designs/stock/stock_renderer.hpp" #include "meta_qt/meta_widget.hpp" -namespace meta::qt +namespace meta::qt::stock { -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) { @@ -176,4 +177,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock 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 index 2cbe612..47822b0 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/color_gradient.inl +++ b/MetaUI/qt/include/meta_qt/widget_renderer_inl/color_gradient.inl @@ -7,17 +7,18 @@ #include #include "meta_common.hpp" +#include "meta_qt/designs/stock/stock_renderer.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 +namespace meta::qt::stock { // --------------------------------------------------------------------------- -// WidgetRenderer +// StockRenderer // // widget_type: "GradientEditor" (default) // @@ -25,7 +26,7 @@ namespace meta::qt // metadata entry (GradientPresets), installed by the host at setup time. // --------------------------------------------------------------------------- -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) @@ -103,4 +104,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/float.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/float.inl index c2a6ad2..1a2bd1b 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/float.inl +++ b/MetaUI/qt/include/meta_qt/widget_renderer_inl/float.inl @@ -14,13 +14,14 @@ #include "meta/type/type_name.hpp" #include "meta_common.hpp" +#include "meta_qt/designs/stock/stock_renderer.hpp" #include "meta_qt/meta_widget.hpp" #include "meta_qt/widgets/slider_float.hpp" -namespace meta::qt +namespace meta::qt::stock { -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) { @@ -220,4 +221,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock 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 index f063b73..80513e8 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_ivec2.inl +++ b/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_ivec2.inl @@ -2,9 +2,10 @@ Public License. The full license is in the file LICENSE, distributed with this software. */ #pragma once +#include "meta_qt/designs/stock/stock_renderer.hpp" #include "meta_qt/widgets/power_of_two_spin_box.hpp" -namespace meta::qt +namespace meta::qt::stock { inline int ceil_power_of_two(int v) @@ -19,7 +20,7 @@ inline int ceil_power_of_two(int v) return p; } -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) { @@ -201,4 +202,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock 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 index 8527f78..7c91461 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec2.inl +++ b/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec2.inl @@ -7,15 +7,16 @@ #include #include "meta/core/data_provider.hpp" +#include "meta_qt/designs/stock/stock_renderer.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 +namespace meta::qt::stock { -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) { @@ -862,4 +863,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock 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 index 6922b1b..c730e02 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec3.inl +++ b/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec3.inl @@ -5,10 +5,12 @@ #include #include -namespace meta::qt +#include "meta_qt/designs/stock/stock_renderer.hpp" + +namespace meta::qt::stock { -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) { @@ -264,4 +266,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock 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 index d785e36..44e6ca4 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec4.inl +++ b/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec4.inl @@ -5,10 +5,12 @@ #include #include -namespace meta::qt +#include "meta_qt/designs/stock/stock_renderer.hpp" + +namespace meta::qt::stock { -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) { @@ -301,4 +303,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock diff --git a/MetaUI/qt/include/meta_qt/widget_renderer_inl/int.inl b/MetaUI/qt/include/meta_qt/widget_renderer_inl/int.inl index 084ff2a..1aadd73 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/int.inl +++ b/MetaUI/qt/include/meta_qt/widget_renderer_inl/int.inl @@ -12,13 +12,14 @@ #include "meta/type/type_name.hpp" #include "meta_common.hpp" +#include "meta_qt/designs/stock/stock_renderer.hpp" #include "meta_qt/meta_widget.hpp" #include "meta_qt/widgets/slider_int.hpp" -namespace meta::qt +namespace meta::qt::stock { -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) { @@ -250,4 +251,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock 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 index 488ea04..b2ab633 100644 --- 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 @@ -8,10 +8,12 @@ #include #include -namespace meta::qt +#include "meta_qt/designs/stock/stock_renderer.hpp" + +namespace meta::qt::stock { -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) @@ -168,4 +170,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock 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 index c989898..00dbf4f 100644 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_string.inl +++ b/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_string.inl @@ -14,10 +14,11 @@ #include "meta/type/type_name.hpp" #include "meta_common.hpp" +#include "meta_qt/designs/stock/stock_renderer.hpp" #include "meta_qt/meta_widget.hpp" #include "meta_qt/widgets/helpers.hpp" -namespace meta::qt +namespace meta::qt::stock { namespace helpers @@ -41,8 +42,8 @@ inline void apply_height_constraints(QPlainTextEdit *te, "ui.max_lines", default_max); - te->setMinimumHeight(plain_text_height(te, min_lines)); - te->setMaximumHeight(plain_text_height(te, max_lines)); + te->setMinimumHeight(meta::qt::helpers::plain_text_height(te, min_lines)); + te->setMaximumHeight(meta::qt::helpers::plain_text_height(te, max_lines)); } // create a small right-aligned "Apply" button row. Returns @@ -50,12 +51,14 @@ inline void apply_height_constraints(QPlainTextEdit *te, inline 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); - - auto *btn_row = new QHBoxLayout(); - btn_row->addStretch(); + apply_btn->setEnabled(false); btn_row->addWidget(apply_btn); return {btn_row, apply_btn}; @@ -63,7 +66,7 @@ inline std::pair make_apply_button( } // namespace helpers -template <> struct WidgetRenderer +template <> struct StockRenderer { static MetaWidget *render(Attribute &attr, QWidget *parent) { @@ -407,4 +410,4 @@ template <> struct WidgetRenderer } }; -} // namespace meta::qt +} // namespace meta::qt::stock 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 index 4cd583f..4c6619f 100644 --- 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 @@ -8,14 +8,14 @@ #include "meta/logger.hpp" -#include "meta_common.hpp" +#include "meta_qt/designs/stock/stock_renderer.hpp" #include "meta_qt/meta_widget.hpp" #include "meta_qt/widgets/curve_canvas.hpp" -namespace meta::qt +namespace meta::qt::stock { -template <> struct WidgetRenderer> +template <> struct StockRenderer> { static MetaWidget *render(Attribute> &attr, QWidget *parent) @@ -147,4 +147,4 @@ template <> struct WidgetRenderer> } }; -} // namespace meta::qt +} // namespace meta::qt::stock 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 index d0bc198..2f68947 100644 --- 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 @@ -13,14 +13,14 @@ #include #include "meta/core/data_provider.hpp" -#include "meta_common.hpp" +#include "meta_qt/designs/stock/stock_renderer.hpp" #include "meta_qt/meta_widget.hpp" #include "meta_qt/widgets/points_canvas.hpp" -namespace meta::qt +namespace meta::qt::stock { -template <> struct WidgetRenderer> +template <> struct StockRenderer> { static MetaWidget *render(Attribute> &attr, QWidget *parent) @@ -224,4 +224,4 @@ template <> struct WidgetRenderer> } }; -} // namespace meta::qt +} // namespace meta::qt::stock diff --git a/MetaUI/qt/src/container_widget/container_widget.cpp b/MetaUI/qt/src/container_widget/container_widget.cpp index 8f45993..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" @@ -68,13 +69,15 @@ namespace /// The stock section unless the caller supplied a design-aware factory. CollapsibleSection *build_section(const SectionFactory §ion_factory, - const QString &title) + const QString &title) { - return section_factory ? section_factory(title) : new CollapsibleSection(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) +MetaWidget *build_row(const AttributeRowRenderer &row_renderer, + AbstractAttribute *p_attr) { return row_renderer ? row_renderer(p_attr) : qt::render(p_attr); } @@ -110,7 +113,10 @@ void render_flat(CategoryNode &node, } for (const auto &name : node.children_order) - render_flat(*node.children.at(name), layout, collected_widgets, row_renderer); + render_flat(*node.children.at(name), + layout, + collected_widgets, + row_renderer); } void render_category(AttributeContainer &container, @@ -354,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; @@ -366,8 +388,8 @@ MetaWidget *render(AttributeContainer &container, layout, collected_widgets, collected_sections, - options.row_renderer, - options.section_factory); + effective_row_renderer, + effective_section_factory); break; case CategoryPolicy::CP_MERGED: @@ -378,15 +400,15 @@ MetaWidget *render(AttributeContainer &container, collected_widgets, collected_sections, options.collapse_regex, - options.row_renderer, - options.section_factory); + 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, options.row_renderer); + render_flat(root, layout, collected_widgets, effective_row_renderer); else render_category_merged(container, root, @@ -394,8 +416,8 @@ MetaWidget *render(AttributeContainer &container, collected_widgets, collected_sections, options.collapse_regex, - options.row_renderer, - options.section_factory); + effective_row_renderer, + effective_section_factory); break; #pragma GCC diagnostic push @@ -404,7 +426,7 @@ MetaWidget *render(AttributeContainer &container, case CategoryPolicy::CP_FLAT: Logger::log()->trace("container_widget::render: flat mode"); default: - render_flat(root, layout, collected_widgets, options.row_renderer); + 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 index 1aae271..d32eb03 100644 --- a/MetaUI/qt/src/designs/industrial/check_row.cpp +++ b/MetaUI/qt/src/designs/industrial/check_row.cpp @@ -13,7 +13,9 @@ namespace meta::qt::industrial { -CheckRow::CheckRow(Attribute &attr, const RowContext &ctx, QWidget *parent) +CheckRow::CheckRow(Attribute &attr, + const RowContext &ctx, + QWidget *parent) : Control(ctx, parent) { key_ = attr.name(); @@ -74,9 +76,10 @@ void CheckRow::paintEvent(QPaintEvent *) 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.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); @@ -102,7 +105,7 @@ void CheckRow::paintEvent(QPaintEvent *) m.radius); // --- knob - const int travel = m.switch_width - m.knob_size - 2 * m.knob_inset; + 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, @@ -144,7 +147,8 @@ void CheckRow::mouseReleaseEvent(QMouseEvent *event) void CheckRow::keyPressEvent(QKeyEvent *event) { - if (!is_locked() && (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return)) + if (!is_locked() && + (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return)) { toggle(); event->accept(); diff --git a/MetaUI/qt/src/designs/industrial/combo.cpp b/MetaUI/qt/src/designs/industrial/combo.cpp index c96fccb..b30f8f3 100644 --- a/MetaUI/qt/src/designs/industrial/combo.cpp +++ b/MetaUI/qt/src/designs/industrial/combo.cpp @@ -7,11 +7,11 @@ #include #include +#include #include #include #include #include -#include #include #include @@ -36,7 +36,10 @@ ComboPopup::ComboPopup(const Theme &theme, const QStringList &items, int current, QWidget *parent) - : QWidget(parent, Qt::Popup), theme_(&theme), items_(items), current_(current), + : 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 @@ -67,7 +70,8 @@ void ComboPopup::popup_for(const QRect &field_global) 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 bool fits_below = field_global.bottom() + full_height_ <= + screen.bottom(); const int left = field_global.left(); flipped_ = !fits_below; @@ -109,7 +113,8 @@ QRect ComboPopup::card_rect() const // 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); + return flipped_ ? QRect(0, full_height_ - h, width(), h) + : QRect(0, 0, width(), h); } int ComboPopup::index_at(const QPoint &pos) const @@ -145,7 +150,10 @@ void ComboPopup::paintEvent(QPaintEvent *) for (int i = 0; i < items_.size(); ++i) { - const QRect row(1, kPopupPadding + i * row_height(), width() - 2, row_height()); + const QRect row(1, + kPopupPadding + i * row_height(), + width() - 2, + row_height()); if (!row.intersects(card)) continue; if (i == hovered_) @@ -247,9 +255,10 @@ void paint_combo_field(QWidget &widget, 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)); + 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); @@ -297,7 +306,9 @@ void paint_combo_field(QWidget &widget, // --- EnumCombo -EnumCombo::EnumCombo(Attribute &attr, const RowContext &ctx, QWidget *parent) +EnumCombo::EnumCombo(Attribute &attr, + const RowContext &ctx, + QWidget *parent) : Control(ctx, parent) { label_ = meta::common::label(attr); @@ -321,7 +332,8 @@ void EnumCombo::set(const int &value) QSize EnumCombo::sizeHint() const { - return QSize(theme().metrics.label_min_width + 160, theme().metrics.row_height); + return QSize(theme().metrics.label_min_width + 160, + theme().metrics.row_height); } void EnumCombo::paintEvent(QPaintEvent *) @@ -389,10 +401,10 @@ void EnumCombo::open_popup() update(); const Metrics &m = theme().metrics; - const int label_width = int(std::clamp(width() * m.label_width_ratio, + 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, + const QRect field(label_width + m.gap, (height() - m.value_field_height) / 2, width() - label_width - m.gap, m.value_field_height); @@ -428,7 +440,8 @@ void StringCombo::set(const std::string &value) QSize StringCombo::sizeHint() const { - return QSize(theme().metrics.label_min_width + 160, theme().metrics.row_height); + return QSize(theme().metrics.label_min_width + 160, + theme().metrics.row_height); } void StringCombo::paintEvent(QPaintEvent *) @@ -490,10 +503,10 @@ void StringCombo::open_popup() update(); const Metrics &m = theme().metrics; - const int label_width = int(std::clamp(width() * m.label_width_ratio, + 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, + const QRect field(label_width + m.gap, (height() - m.value_field_height) / 2, width() - label_width - m.gap, m.value_field_height); diff --git a/MetaUI/qt/src/designs/industrial/industrial.cpp b/MetaUI/qt/src/designs/industrial/industrial.cpp index cdbc67c..c60a15d 100644 --- a/MetaUI/qt/src/designs/industrial/industrial.cpp +++ b/MetaUI/qt/src/designs/industrial/industrial.cpp @@ -7,8 +7,10 @@ #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 { @@ -21,6 +23,17 @@ void register_design() DesignRegistry ®istry = DesignRegistry::instance(); + // --- Theme & section chrome + registry.set_theme(kDesignName, ThemeRegistry::kPaletteTheme); + registry.register_section_factory( + kDesignName, + [](const QString &title) + { + const Theme &theme = ThemeRegistry::instance().get( + ThemeRegistry::kPaletteTheme); + return new Section(title, theme); + }); + // --- float: 58% of the rows in a Hesiod node panel registry.register_control(kDesignName, "SliderFloat"); @@ -39,7 +52,8 @@ void register_design() // shows one surface mid-open and another once settled. registry.register_control(kDesignName, "EnumComboBox"); registry.register_control(kDesignName, "ComboBox"); - registry.register_control(kDesignName, "ButtonGrid"); + 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 diff --git a/MetaUI/qt/src/designs/industrial/int_slider.cpp b/MetaUI/qt/src/designs/industrial/int_slider.cpp index 0292d37..91251d4 100644 --- a/MetaUI/qt/src/designs/industrial/int_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/int_slider.cpp @@ -15,7 +15,9 @@ namespace meta::qt::industrial { -IntSlider::IntSlider(Attribute &attr, const RowContext &ctx, QWidget *parent) +IntSlider::IntSlider(Attribute &attr, + const RowContext &ctx, + QWidget *parent) : Control(ctx, parent) { key_ = attr.name(); @@ -80,7 +82,10 @@ IntSlider::IntSlider(Attribute &attr, const RowContext &ctx, QWidget *paren apply_value(std::clamp(typed, min_, max_), true); }); - connect(field_, &QLineEdit::textEdited, this, [this]() { restyle_field(true); }); + connect(field_, + &QLineEdit::textEdited, + this, + [this]() { restyle_field(true); }); } bool IntSlider::can_render(const Attribute &attr) @@ -104,7 +109,8 @@ void IntSlider::set(const int &value) QSize IntSlider::sizeHint() const { - return QSize(theme().metrics.label_min_width + 200, theme().metrics.row_height); + return QSize(theme().metrics.label_min_width + 200, + theme().metrics.row_height); } qreal IntSlider::to_norm(int value) const @@ -151,7 +157,8 @@ void IntSlider::resizeEvent(QResizeEvent *event) return; } - field_->setGeometry(SliderGeometry::compute(theme(), width(), height(), norm_).field); + field_->setGeometry( + SliderGeometry::compute(theme(), width(), height(), norm_).field); QWidget::resizeEvent(event); } @@ -164,7 +171,8 @@ void IntSlider::mousePressEvent(QMouseEvent *event) return; } - const QRect rail = SliderGeometry::compute(theme(), width(), height(), norm_).rail; + const QRect rail = SliderGeometry::compute(theme(), width(), height(), norm_) + .rail; if (!rail.adjusted(-4, -10, 4, 10).contains(event->pos())) { event->ignore(); @@ -254,7 +262,10 @@ bool IntSlider::eventFilter(QObject *watched, QEvent *event) void IntSlider::set_from_position(int x) { const Metrics &m = theme().metrics; - const SliderGeometry g = SliderGeometry::compute(theme(), width(), height(), norm_); + 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, @@ -300,7 +311,8 @@ void IntSlider::restyle_field(bool editing) if (!field_) return; field_->setReadOnly(is_locked()); - field_->setStyleSheet(field_stylesheet(theme(), editing, is_modified(), is_locked())); + field_->setStyleSheet( + field_stylesheet(theme(), editing, is_modified(), is_locked())); } } // namespace meta::qt::industrial diff --git a/MetaUI/qt/src/designs/industrial/param_slider.cpp b/MetaUI/qt/src/designs/industrial/param_slider.cpp index c160f72..01c80db 100644 --- a/MetaUI/qt/src/designs/industrial/param_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/param_slider.cpp @@ -6,8 +6,8 @@ #include #include -#include #include +#include #include #include #include @@ -21,7 +21,9 @@ namespace constexpr qreal kLogFloor = 1e-6; ///< below this a log mapping is undefined } -ParamSlider::ParamSlider(Attribute &attr, const RowContext &ctx, QWidget *parent) +ParamSlider::ParamSlider(Attribute &attr, + const RowContext &ctx, + QWidget *parent) : Control(ctx, parent) { key_ = attr.name(); @@ -29,7 +31,9 @@ ParamSlider::ParamSlider(Attribute &attr, const RowContext &ctx, QWidget 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); + 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 @@ -95,7 +99,10 @@ ParamSlider::ParamSlider(Attribute &attr, const RowContext &ctx, QWidget glide_->to(to_norm(std::clamp(typed, min_, max_))); }); - connect(field_, &QLineEdit::textEdited, this, [this]() { restyle_field(true); }); + connect(field_, + &QLineEdit::textEdited, + this, + [this]() { restyle_field(true); }); } bool ParamSlider::can_render(const Attribute &attr) @@ -125,7 +132,8 @@ void ParamSlider::set(const float &value) QSize ParamSlider::sizeHint() const { - return QSize(theme().metrics.label_min_width + 200, theme().metrics.row_height); + return QSize(theme().metrics.label_min_width + 200, + theme().metrics.row_height); } // --- value mapping @@ -161,7 +169,6 @@ float ParamSlider::from_norm(qreal t) const // --- painting - void ParamSlider::paintEvent(QPaintEvent *) { QPainter painter(this); @@ -195,7 +202,8 @@ void ParamSlider::resizeEvent(QResizeEvent *event) return; } - field_->setGeometry(SliderGeometry::compute(theme(), width(), height(), norm_).field); + field_->setGeometry( + SliderGeometry::compute(theme(), width(), height(), norm_).field); QWidget::resizeEvent(event); } @@ -210,7 +218,8 @@ void ParamSlider::mousePressEvent(QMouseEvent *event) return; } - const QRect rail = SliderGeometry::compute(theme(), width(), height(), norm_).rail; + const QRect rail = SliderGeometry::compute(theme(), width(), height(), norm_) + .rail; if (!rail.adjusted(-4, -10, 4, 10).contains(event->pos())) { event->ignore(); @@ -304,7 +313,10 @@ bool ParamSlider::eventFilter(QObject *watched, QEvent *event) void ParamSlider::set_from_position(int x) { const Metrics &m = theme().metrics; - const SliderGeometry g = SliderGeometry::compute(theme(), width(), height(), norm_); + const SliderGeometry g = SliderGeometry::compute(theme(), + width(), + height(), + norm_); const int travel = std::max(1, g.rail.width() - m.thumb_width); apply_norm( @@ -345,17 +357,18 @@ void ParamSlider::restyle_field(bool editing) 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())); + 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 index a1c4da2..eb657ac 100644 --- a/MetaUI/qt/src/designs/industrial/section.cpp +++ b/MetaUI/qt/src/designs/industrial/section.cpp @@ -9,8 +9,8 @@ #include #include #include -#include #include +#include #include "meta/logger.hpp" diff --git a/MetaUI/qt/src/designs/industrial/slider_chrome.cpp b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp index 09a02b7..b1a97a5 100644 --- a/MetaUI/qt/src/designs/industrial/slider_chrome.cpp +++ b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp @@ -26,26 +26,32 @@ SliderGeometry SliderGeometry::compute(const Theme &theme, 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; + 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); + 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)), + 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.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, @@ -70,7 +76,9 @@ void paint_slider_row(QPainter &painter, 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); + painter.drawText(geometry.label, + Qt::AlignLeft | Qt::AlignVCenter, + visual.label); if (geometry.rail.width() <= 0) return; @@ -86,9 +94,10 @@ void paint_slider_row(QPainter &painter, { 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); + painter.drawRoundedRect( + QRectF(geometry.fill).adjusted(0.5, 0.5, -0.5, -0.5), + m.rail_radius, + m.rail_radius); } // --- thumb @@ -107,13 +116,18 @@ void paint_slider_row(QPainter &painter, // 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.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) +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; diff --git a/MetaUI/qt/src/designs/stock/stock.cpp b/MetaUI/qt/src/designs/stock/stock.cpp index 4c2b07a..7e65986 100644 --- a/MetaUI/qt/src/designs/stock/stock.cpp +++ b/MetaUI/qt/src/designs/stock/stock.cpp @@ -1,10 +1,32 @@ -/* 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 "meta_qt/designs/stock/stock_renderer.hpp" #include "meta_qt/ui/design_registry.hpp" -#include "meta_qt/widget_renderer.hpp" +#include "meta_qt/widgets/collapsible_section.hpp" + +// Stock widget builders +#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 + +#ifdef META_ENABLE_ARRAY_TYPES +#include "meta_qt/widget_renderer_inl/array.inl" +#endif namespace meta::qt::stock { @@ -12,23 +34,24 @@ namespace meta::qt::stock namespace { -/** @brief Wrap WidgetRenderer as a row factory. +/** @brief Wrap StockRenderer as a row factory. * - * Registered under the wildcard widget_type: WidgetRenderer already - * branches on widget_type internally, so splitting those branches into - * separate entries would be a rewrite rather than a registration. Doing that - * later would remove the inner if-chains too, but it is not needed to make the - * registry the single dispatch point. + * Registered under specific widget_types and the wildcard widget_type. */ template RowFactory wrap() { - return [](AbstractAttribute &attr, const RowContext &, QWidget *parent) -> MetaWidget * - { return WidgetRenderer::render(static_cast &>(attr), parent); }; + return [](AbstractAttribute &attr, + const RowContext &, + QWidget *parent) -> MetaWidget * { + return StockRenderer::render(static_cast &>(attr), parent); + }; } -template void add(DesignRegistry ®istry) +template +void add(DesignRegistry ®istry, + const std::string &widget_type = kAnyWidgetType) { - registry.add(kDesignName, std::type_index(typeid(T)), kAnyWidgetType, wrap()); + registry.add(kDesignName, std::type_index(typeid(T)), widget_type, wrap()); } } // namespace @@ -41,27 +64,69 @@ void register_design() DesignRegistry ®istry = DesignRegistry::instance(); - add(registry); - add(registry); - add(registry); - add(registry); - add(registry); - add>(registry); + // --- Section factory + registry.register_section_factory(kDesignName, + [](const QString &title) + { return new CollapsibleSection(title); }); + + // --- Granular registrations for common widgets + add(registry, "Toggle"); + add(registry, "Checkbox"); + add(registry, "BinaryButtons"); + add(registry, kAnyWidgetType); + + add(registry, "Input"); + add(registry, "Slider"); + add(registry, "ScrollBar"); + add(registry, "Dial"); + add(registry, "SliderFloat"); + add(registry, kAnyWidgetType); + + add(registry, "Input"); + add(registry, "Slider"); + add(registry, "ScrollBar"); + add(registry, "Dial"); + add(registry, "SliderInt"); + add(registry, "EnumComboBox"); + add(registry, kAnyWidgetType); + + add(registry, "ComboBox"); + add(registry, "ButtonGrid"); + add(registry, "SingleLineText"); + add(registry, "MultilineText"); + add(registry, "CodeEditor"); + add(registry, "ReadOnlyText"); + add(registry, kAnyWidgetType); + + add(registry, "OpenFile"); + add(registry, "SaveFile"); + add(registry, "Directory"); + add(registry, kAnyWidgetType); + + add>(registry, kAnyWidgetType); #ifdef META_ENABLE_GLM_TYPES - add(registry); - add(registry); - add(registry); - add(registry); - add>(registry); + add(registry, kAnyWidgetType); + add(registry, "XYCanvas"); + add(registry, "VectorEditor"); + add(registry, "LinkedSliders"); + add(registry, "RangeBar"); + add(registry, kAnyWidgetType); + add(registry, "ColorPicker"); + add(registry, kAnyWidgetType); + add(registry, "ColorPicker"); + add(registry, kAnyWidgetType); + add>(registry, "PointsEditor"); + add>(registry, "PathEditor"); + add>(registry, kAnyWidgetType); #endif #ifdef META_ENABLE_COLOR_GRADIENT_TYPES - add(registry); + add(registry, kAnyWidgetType); #endif #ifdef META_ENABLE_ARRAY_TYPES - add(registry); + add(registry, kAnyWidgetType); #endif } diff --git a/MetaUI/qt/src/ui/control.cpp b/MetaUI/qt/src/ui/control.cpp index 6b4bcf0..98ef1df 100644 --- a/MetaUI/qt/src/ui/control.cpp +++ b/MetaUI/qt/src/ui/control.cpp @@ -10,7 +10,8 @@ namespace meta::qt { ControlBase::ControlBase(const RowContext &ctx, QWidget *parent) - : QWidget(parent), ctx_(ctx), + : QWidget(parent), + ctx_(ctx), theme_(ctx.theme ? ctx.theme : &ThemeRegistry::instance().fallback()) { setFocusPolicy(Qt::StrongFocus); @@ -46,7 +47,9 @@ void ControlBase::end_edit() void ControlBase::notify_value_changed() { Q_EMIT value_changed(); } -QString ControlBase::elide_label(const QString &text, const QFont &font, int width) +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); diff --git a/MetaUI/qt/src/ui/design_registry.cpp b/MetaUI/qt/src/ui/design_registry.cpp index 6e1d2f1..4241af8 100644 --- a/MetaUI/qt/src/ui/design_registry.cpp +++ b/MetaUI/qt/src/ui/design_registry.cpp @@ -24,7 +24,8 @@ void DesignRegistry::add(const std::string &design, factories_[design][Key{type, widget_type}] = std::move(factory); } -void DesignRegistry::set_fallback(const std::string &design, const std::string &fallback) +void DesignRegistry::set_fallback(const std::string &design, + const std::string &fallback) { fallbacks_[design] = fallback; } @@ -59,32 +60,111 @@ MetaWidget *DesignRegistry::render(AbstractAttribute *p_attr, // 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 (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; + 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; + 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(); + 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()); + 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; } @@ -99,15 +179,18 @@ MetaWidget *render_row(AbstractAttribute *p_attr, return nullptr; } - MetaWidget *row = DesignRegistry::instance().render(p_attr, design, ctx, parent); + 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()); + 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; } diff --git a/MetaUI/qt/src/ui/theme.cpp b/MetaUI/qt/src/ui/theme.cpp index 0f502f3..3ffaecc 100644 --- a/MetaUI/qt/src/ui/theme.cpp +++ b/MetaUI/qt/src/ui/theme.cpp @@ -27,8 +27,7 @@ QFont mono_font(int pixel_size) const QStringList available = QFontDatabase::families(); for (const QString &candidate : candidates) - if (available.contains(candidate)) - return candidate; + if (available.contains(candidate)) return candidate; return QFontDatabase::systemFont(QFontDatabase::FixedFont).family(); }(); @@ -57,8 +56,7 @@ QFont ui_font(int pixel_size, bool bold, qreal letter_spacing) const QStringList available = QFontDatabase::families(); for (const QString &candidate : candidates) - if (available.contains(candidate)) - return candidate; + if (available.contains(candidate)) return candidate; return QString(); }(); @@ -89,7 +87,10 @@ QColor mix(const QColor &a, const QColor &b, qreal t) 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); } +QColor lift(const QColor &c, qreal t) +{ + return mix(c, QColor(255, 255, 255), t); +} } // namespace @@ -144,11 +145,12 @@ Theme Theme::from_palette(const QPalette &palette, const std::string &name) // 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; + 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; @@ -180,7 +182,10 @@ QColor Theme::rail_fill(const std::string &category, bool locked) const return c; } -QColor Theme::switch_track_on() const { return accent.darker(int(switch_track_on_darker * 100)); } +QColor Theme::switch_track_on() const +{ + return accent.darker(int(switch_track_on_darker * 100)); +} QColor Theme::state_ink(bool modified, bool locked) const { 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/points_canvas.cpp b/MetaUI/qt/src/widgets/points_canvas.cpp index 3882884..0146eb7 100644 --- a/MetaUI/qt/src/widgets/points_canvas.cpp +++ b/MetaUI/qt/src/widgets/points_canvas.cpp @@ -10,8 +10,8 @@ #include #include #include -#include #include +#include #include #include @@ -50,7 +50,8 @@ 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()); + if (event->oldSize().width() != event->size().width()) + setFixedHeight(width()); QWidget::resizeEvent(event); } diff --git a/tests/test_qt/test_meta_qt/main.cpp b/tests/test_qt/test_meta_qt/main.cpp index c3401c9..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; @@ -946,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); @@ -956,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 @@ -1007,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)"); } // --------------------------------------------------------------------------- @@ -1052,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 ff0a7fc..79d94c0 100644 --- a/tests/unittests/CMakeLists.txt +++ b/tests/unittests/CMakeLists.txt @@ -16,7 +16,7 @@ endif() enable_testing() -add_executable(meta_unittests +set(UNITTEST_SOURCES main.cpp test_attribute.cpp test_attribute_container.cpp @@ -26,10 +26,30 @@ add_executable(meta_unittests 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_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; + } +} From 138bafd6decd8436adc7836c7a4be643a2d4c6bd Mon Sep 17 00:00:00 2001 From: Otto Link Date: Thu, 3 Sep 2026 16:42:39 +0200 Subject: [PATCH 13/14] refactor(qt): move stock widget implementations to source files and remove header templates --- .../meta_qt/designs/stock/stock_renderer.hpp | 39 - .../meta_qt/widget_renderer_inl/array.inl | 279 --- .../meta_qt/widget_renderer_inl/bool.inl | 180 -- .../widget_renderer_inl/color_gradient.inl | 107 -- .../meta_qt/widget_renderer_inl/float.inl | 224 --- .../meta_qt/widget_renderer_inl/glm_ivec2.inl | 205 --- .../meta_qt/widget_renderer_inl/glm_vec2.inl | 866 --------- .../meta_qt/widget_renderer_inl/glm_vec3.inl | 269 --- .../meta_qt/widget_renderer_inl/glm_vec4.inl | 306 ---- .../meta_qt/widget_renderer_inl/int.inl | 254 --- .../std_filesystem_path.inl | 173 -- .../widget_renderer_inl/std_string.inl | 413 ----- .../widget_renderer_inl/std_vector_float.inl | 150 -- .../std_vector_glm_vec3.inl | 227 --- MetaUI/qt/src/designs/stock/stock.cpp | 117 +- MetaUI/qt/src/designs/stock/stock_bool.cpp | 181 ++ .../qt/src/designs/stock/stock_filesystem.cpp | 179 ++ MetaUI/qt/src/designs/stock/stock_glm.cpp | 1558 +++++++++++++++++ .../qt/src/designs/stock/stock_internal.hpp | 21 + MetaUI/qt/src/designs/stock/stock_misc.cpp | 482 +++++ MetaUI/qt/src/designs/stock/stock_numeric.cpp | 459 +++++ MetaUI/qt/src/designs/stock/stock_string.cpp | 397 +++++ 22 files changed, 3289 insertions(+), 3797 deletions(-) delete mode 100644 MetaUI/qt/include/meta_qt/designs/stock/stock_renderer.hpp delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/array.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/bool.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/color_gradient.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/float.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_ivec2.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec2.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec3.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec4.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/int.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/std_filesystem_path.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/std_string.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/std_vector_float.inl delete mode 100644 MetaUI/qt/include/meta_qt/widget_renderer_inl/std_vector_glm_vec3.inl create mode 100644 MetaUI/qt/src/designs/stock/stock_bool.cpp create mode 100644 MetaUI/qt/src/designs/stock/stock_filesystem.cpp create mode 100644 MetaUI/qt/src/designs/stock/stock_glm.cpp create mode 100644 MetaUI/qt/src/designs/stock/stock_internal.hpp create mode 100644 MetaUI/qt/src/designs/stock/stock_misc.cpp create mode 100644 MetaUI/qt/src/designs/stock/stock_numeric.cpp create mode 100644 MetaUI/qt/src/designs/stock/stock_string.cpp diff --git a/MetaUI/qt/include/meta_qt/designs/stock/stock_renderer.hpp b/MetaUI/qt/include/meta_qt/designs/stock/stock_renderer.hpp deleted file mode 100644 index 9120ce9..0000000 --- a/MetaUI/qt/include/meta_qt/designs/stock/stock_renderer.hpp +++ /dev/null @@ -1,39 +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/core/attribute.hpp" -#include "meta/type/type_name.hpp" -#include "meta_common.hpp" -#include "meta_qt/meta_widget.hpp" - -namespace meta::qt::stock -{ - -/// Fallback renderer for unsupported stock types. -template struct StockRenderer -{ - 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; - } -}; - -} // namespace meta::qt::stock 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 78c0859..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/array.inl +++ /dev/null @@ -1,279 +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/ext/array/array.hpp" -#include "meta_common.hpp" -#include "meta_qt/designs/stock/stock_renderer.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/array_canvas.hpp" -#include "meta_qt/widgets/points_canvas.hpp" -#include -#include -#include -#include - -namespace meta::qt::stock -{ - -// ===================================== -// 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; -} - -// --------------------------------------------------------------------------- -// StockRenderer -// --------------------------------------------------------------------------- - -template <> struct StockRenderer -{ - 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::stock 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 c4240b0..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/bool.inl +++ /dev/null @@ -1,180 +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/designs/stock/stock_renderer.hpp" -#include "meta_qt/meta_widget.hpp" - -namespace meta::qt::stock -{ - -template <> struct StockRenderer -{ - 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::stock 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 47822b0..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/color_gradient.inl +++ /dev/null @@ -1,107 +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/designs/stock/stock_renderer.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::stock -{ - -// --------------------------------------------------------------------------- -// StockRenderer -// -// 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 StockRenderer -{ - 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::stock 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 1a2bd1b..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/float.inl +++ /dev/null @@ -1,224 +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/designs/stock/stock_renderer.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/slider_float.hpp" - -namespace meta::qt::stock -{ - -template <> struct StockRenderer -{ - 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::stock 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 80513e8..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_ivec2.inl +++ /dev/null @@ -1,205 +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/designs/stock/stock_renderer.hpp" -#include "meta_qt/widgets/power_of_two_spin_box.hpp" - -namespace meta::qt::stock -{ - -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 StockRenderer -{ - 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::stock 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 7c91461..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec2.inl +++ /dev/null @@ -1,866 +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/designs/stock/stock_renderer.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::stock -{ - -template <> struct StockRenderer -{ - 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::stock 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 c730e02..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec3.inl +++ /dev/null @@ -1,269 +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_qt/designs/stock/stock_renderer.hpp" - -namespace meta::qt::stock -{ - -template <> struct StockRenderer -{ - 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::stock 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 44e6ca4..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/glm_vec4.inl +++ /dev/null @@ -1,306 +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_qt/designs/stock/stock_renderer.hpp" - -namespace meta::qt::stock -{ - -template <> struct StockRenderer -{ - 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::stock 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 1aadd73..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/int.inl +++ /dev/null @@ -1,254 +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/designs/stock/stock_renderer.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/slider_int.hpp" - -namespace meta::qt::stock -{ - -template <> struct StockRenderer -{ - 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::stock 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 b2ab633..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_filesystem_path.inl +++ /dev/null @@ -1,173 +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 "meta_qt/designs/stock/stock_renderer.hpp" - -namespace meta::qt::stock -{ - -template <> struct StockRenderer -{ - 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::stock 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 00dbf4f..0000000 --- a/MetaUI/qt/include/meta_qt/widget_renderer_inl/std_string.inl +++ /dev/null @@ -1,413 +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/designs/stock/stock_renderer.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/helpers.hpp" - -namespace meta::qt::stock -{ - -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(meta::qt::helpers::plain_text_height(te, min_lines)); - te->setMaximumHeight(meta::qt::helpers::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 *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}; -} - -} // namespace helpers - -template <> struct StockRenderer -{ - 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::stock 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 4c6619f..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_qt/designs/stock/stock_renderer.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/curve_canvas.hpp" - -namespace meta::qt::stock -{ - -template <> struct StockRenderer> -{ - 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::stock 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 2f68947..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_qt/designs/stock/stock_renderer.hpp" -#include "meta_qt/meta_widget.hpp" -#include "meta_qt/widgets/points_canvas.hpp" - -namespace meta::qt::stock -{ - -template <> struct StockRenderer> -{ - 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::stock diff --git a/MetaUI/qt/src/designs/stock/stock.cpp b/MetaUI/qt/src/designs/stock/stock.cpp index 7e65986..fa8cdf8 100644 --- a/MetaUI/qt/src/designs/stock/stock.cpp +++ b/MetaUI/qt/src/designs/stock/stock.cpp @@ -1,61 +1,16 @@ +/* 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 "meta_qt/designs/stock/stock_renderer.hpp" +#include "stock_internal.hpp" + #include "meta_qt/ui/design_registry.hpp" #include "meta_qt/widgets/collapsible_section.hpp" -// Stock widget builders -#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 - -#ifdef META_ENABLE_ARRAY_TYPES -#include "meta_qt/widget_renderer_inl/array.inl" -#endif - namespace meta::qt::stock { -namespace -{ - -/** @brief Wrap StockRenderer as a row factory. - * - * Registered under specific widget_types and the wildcard widget_type. - */ -template RowFactory wrap() -{ - return [](AbstractAttribute &attr, - const RowContext &, - QWidget *parent) -> MetaWidget * { - return StockRenderer::render(static_cast &>(attr), parent); - }; -} - -template -void add(DesignRegistry ®istry, - const std::string &widget_type = kAnyWidgetType) -{ - registry.add(kDesignName, std::type_index(typeid(T)), widget_type, wrap()); -} - -} // namespace - void register_design() { static bool registered = false; @@ -69,65 +24,17 @@ void register_design() [](const QString &title) { return new CollapsibleSection(title); }); - // --- Granular registrations for common widgets - add(registry, "Toggle"); - add(registry, "Checkbox"); - add(registry, "BinaryButtons"); - add(registry, kAnyWidgetType); - - add(registry, "Input"); - add(registry, "Slider"); - add(registry, "ScrollBar"); - add(registry, "Dial"); - add(registry, "SliderFloat"); - add(registry, kAnyWidgetType); - - add(registry, "Input"); - add(registry, "Slider"); - add(registry, "ScrollBar"); - add(registry, "Dial"); - add(registry, "SliderInt"); - add(registry, "EnumComboBox"); - add(registry, kAnyWidgetType); - - add(registry, "ComboBox"); - add(registry, "ButtonGrid"); - add(registry, "SingleLineText"); - add(registry, "MultilineText"); - add(registry, "CodeEditor"); - add(registry, "ReadOnlyText"); - add(registry, kAnyWidgetType); - - add(registry, "OpenFile"); - add(registry, "SaveFile"); - add(registry, "Directory"); - add(registry, kAnyWidgetType); - - add>(registry, kAnyWidgetType); + // --- Category widget registrations + register_stock_bool(registry); + register_stock_numeric(registry); + register_stock_string(registry); + register_stock_filesystem(registry); #ifdef META_ENABLE_GLM_TYPES - add(registry, kAnyWidgetType); - add(registry, "XYCanvas"); - add(registry, "VectorEditor"); - add(registry, "LinkedSliders"); - add(registry, "RangeBar"); - add(registry, kAnyWidgetType); - add(registry, "ColorPicker"); - add(registry, kAnyWidgetType); - add(registry, "ColorPicker"); - add(registry, kAnyWidgetType); - add>(registry, "PointsEditor"); - add>(registry, "PathEditor"); - add>(registry, kAnyWidgetType); + register_stock_glm(registry); #endif -#ifdef META_ENABLE_COLOR_GRADIENT_TYPES - add(registry, kAnyWidgetType); -#endif - -#ifdef META_ENABLE_ARRAY_TYPES - add(registry, kAnyWidgetType); -#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 From 67394140c3d3901c610929706b1fcb006ea91409 Mon Sep 17 00:00:00 2001 From: Leonhardmaster2 Date: Fri, 4 Sep 2026 04:54:24 +0200 Subject: [PATCH 14/14] fix(qt): let the industrial section factory follow the design's theme The factory read kPaletteTheme directly instead of the theme set for the design, so set_theme() had no effect on section chrome. A host that picks a colourway got it on the rows but not on the section cards, and the two drew different greys. Resolved per section rather than captured, since the theme can be set after the design registers. --- MetaUI/qt/src/designs/industrial/industrial.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/MetaUI/qt/src/designs/industrial/industrial.cpp b/MetaUI/qt/src/designs/industrial/industrial.cpp index c60a15d..188d7be 100644 --- a/MetaUI/qt/src/designs/industrial/industrial.cpp +++ b/MetaUI/qt/src/designs/industrial/industrial.cpp @@ -29,9 +29,14 @@ void register_design() kDesignName, [](const QString &title) { - const Theme &theme = ThemeRegistry::instance().get( - ThemeRegistry::kPaletteTheme); - return new Section(title, theme); + // 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