diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/editor_style.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/editor_style.hpp new file mode 100644 index 0000000..33600f0 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/editor_style.hpp @@ -0,0 +1,89 @@ +/* Copyright (c) 2026 Otto Link. Distributed under the terms of the GNU General + Public License. The full license is in the file LICENSE, distributed with + this software. */ +#pragma once +#include "meta_qt/ui/theme.hpp" +#include +#include +#include +#include +namespace meta::qt::industrial +{ +inline void style_editor(QWidget *host, const Theme &theme) +{ + host->setObjectName("IndustrialEditor"); + QPalette palette = host->palette(); + palette.setColor(QPalette::Base, theme.bar); + palette.setColor(QPalette::Window, theme.section_surface); + palette.setColor(QPalette::Text, theme.ink_primary); + palette.setColor(QPalette::WindowText, theme.ink_primary); + palette.setColor(QPalette::ButtonText, theme.ink_primary); + palette.setColor(QPalette::Button, theme.field); + palette.setColor(QPalette::Mid, theme.field_border); + palette.setColor(QPalette::Dark, theme.hairline); + palette.setColor(QPalette::Light, theme.thumb_top); + palette.setColor(QPalette::Highlight, theme.accent); + palette.setColor(QPalette::PlaceholderText, theme.ink_secondary); + for (auto role : {QPalette::Text, QPalette::WindowText, QPalette::ButtonText}) + palette.setColor(QPalette::Disabled, role, theme.ink_dim); + host->setPalette(palette); + host->setFont(row_label_font()); + host->setStyleSheet( + QString( + "#IndustrialEditor QLabel { color: %1; background: transparent; }" + "#IndustrialEditor QPushButton, #IndustrialEditor QToolButton, " + "#IndustrialEditor QComboBox {" + " color: %1; background: %2; border: 1px solid %3; border-radius: 5px; " + "padding: 3px 7px; }" + "#IndustrialEditor QPushButton:hover, #IndustrialEditor QToolButton:hover { " + "border-color: %4; }" + "#IndustrialEditor QPushButton:checked { background: %4; color: %1; }" + "#IndustrialEditor QPushButton:disabled, #IndustrialEditor " + "QToolButton:disabled { color: %5; background: transparent; border-color: %3; }" + "#IndustrialEditor QScrollArea { border: none; background: transparent; }" + "#IndustrialEditor QScrollBar:vertical { width: 8px; background: transparent; }" + "#IndustrialEditor QScrollBar::handle:vertical { background: %3; " + "border-radius: 3px; min-height: 24px; }" + "#IndustrialEditor QScrollBar::add-line:vertical, #IndustrialEditor " + "QScrollBar::sub-line:vertical { height: 0px; }" + "#IndustrialEditor QScrollBar::add-page:vertical, #IndustrialEditor " + "QScrollBar::sub-page:vertical { background: transparent; }" + "#IndustrialEditor QComboBox::drop-down { border: none; width: 20px; }" + "#IndustrialEditor QToolButton::menu-indicator { image: none; }" + "#IndustrialEditor QPushButton[preset_name] { padding: 2px; border: 2px solid " + "transparent; border-radius: 5px; background: transparent; }" + "#IndustrialEditor QPushButton[preset_name]:hover { border-color: %5; }" + "#IndustrialEditor QPushButton[preset_name]:checked { border-color: %4; " + "background: transparent; }") + .arg(theme.ink_primary.name(), + theme.field.name(), + theme.field_border.name(), + theme.accent.name(), + theme.ink_dim.name())); + if (host->layout()) + host->layout()->setSpacing(8); + for (auto *child : host->findChildren()) + { + child->setProperty("industrialEditor", true); + child->setPalette(palette); + } + for (auto *label : host->findChildren()) + { + label->setFont(row_label_font()); + auto text = label->text(); + if (!text.isEmpty()) + { + text[0] = text[0].toUpper(); + label->setText(text); + } + } + for (auto *button : host->findChildren()) + { + if (button->property("preset_name").isValid()) + continue; + button->setFont(ui_font(12)); + button->setFixedHeight(28); + button->setCursor(Qt::PointingHandCursor); + } +} +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp index 6148b15..bcddc4d 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp @@ -86,6 +86,7 @@ class IntSlider : public Control int min_ = 0; int max_ = 1; + int input_max_ = 1; int value_ = 0; std::string label_; std::string category_; diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/linked_sliders.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/linked_sliders.hpp new file mode 100644 index 0000000..6ff7511 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/linked_sliders.hpp @@ -0,0 +1,37 @@ +/* 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 +#ifdef META_ENABLE_GLM_TYPES +#include "meta_common.hpp" +#include "meta_qt/designs/industrial/param_slider.hpp" +#include "meta_qt/ui/control.hpp" +#include +class QToolButton; +namespace meta::qt::industrial +{ +// Shared scalar controls give bounded and unbounded pairs identical behavior. +class LinkedSliders : public Control +{ + Q_OBJECT +public: + LinkedSliders(Attribute &, const RowContext &, QWidget * = nullptr); + static bool can_render(const Attribute &) { return true; } + glm::vec2 get() const override { return value_; } + void set(const glm::vec2 &) override; + QSize sizeHint() const override; + +protected: + void on_state_changed() override; + +private: + Theme axes_theme_; + Attribute axes_[2] = {{"x", 0.f}, {"y", 0.f}}; + ParamSlider *sliders_[2] = {nullptr, nullptr}; + QToolButton *link_ = nullptr; + AttributeContainer *state_ = nullptr; + glm::vec2 value_{}; + bool linked_ = false; +}; +} // namespace meta::qt::industrial +#endif 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 a84ec16..c47c291 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp @@ -94,6 +94,7 @@ class ParamSlider : public Control float min_ = 0.f; float max_ = 1.f; + float input_max_ = 1.f; float value_ = 0.f; bool log_scale_ = false; int decimals_ = 2; diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/text_row.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/text_row.hpp new file mode 100644 index 0000000..beba103 --- /dev/null +++ b/MetaUI/qt/include/meta_qt/designs/industrial/text_row.hpp @@ -0,0 +1,74 @@ +/* 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" + +class QLineEdit; + +namespace meta::qt::industrial +{ + +/** @brief Label and a text field, for a std::string attribute. + * + * Covers both SingleLineText and ReadOnlyText, which differ only in whether + * the field accepts typing. 20 rows in a Hesiod panel. + * + * The field stretches from the label column to the right edge rather than + * using the sliders' narrow value box. A slider's readout is a number a few + * characters wide sitting beside a rail that needs the room; a text row has no + * rail and its content is the whole point, so giving it the same 74px box + * would truncate almost everything worth typing. + * + * Chrome comes from field_stylesheet, the same function the slider readouts + * use, so the two kinds of field are the same object at different widths. + */ +class TextRow : public Control +{ + Q_OBJECT + +public: + TextRow(Attribute &attr, const RowContext &ctx, QWidget *parent = nullptr); + + /// Any string attribute. There is no metadata this row cannot honour. + 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 resizeEvent(QResizeEvent *event) override; + void on_state_changed() override; + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + /// Label column, matching the sliders so the two line up down the panel. + QRect label_rect() const; + + /// Everything to the right of the label. + QRect field_rect() const; + + void refresh_field(); + void restyle_field(bool editing = false); + + std::string value_; + std::string label_; + + /** @brief Declared read only by the attribute, as opposed to locked. + * + * Separate from is_locked(), which is a runtime state the host drives. A + * ReadOnlyText is never editable whatever the host says. + */ + bool read_only_ = false; + + QLineEdit *field_ = nullptr; +}; + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/ui/number_format.hpp b/MetaUI/qt/include/meta_qt/ui/number_format.hpp new file mode 100644 index 0000000..863cc0d --- /dev/null +++ b/MetaUI/qt/include/meta_qt/ui/number_format.hpp @@ -0,0 +1,27 @@ +/* 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 +namespace meta::qt +{ +// Ordinary values stay compact; small nonzero values must never read as zero. +inline QString display_float(float value, int decimals = 2) +{ + const double magnitude = std::abs(double(value)); + if (!std::isfinite(value)) + return QString::number(value); + decimals = std::clamp(decimals, 0, 8); + if (magnitude == 0 || magnitude >= std::pow(10., -decimals)) + return QString::number(value == 0 ? 0 : value, 'f', decimals); + if (magnitude < 1e-10) + return QString::number(value, 'g', 6); + const int precision = std::min(12, int(std::ceil(-std::log10(magnitude))) + 5); + QString text = QString::number(value, 'f', precision); + while (text.endsWith('0') && text.size() - text.indexOf('.') - 1 > decimals) + text.chop(1); + return text; +} +} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widgets/array_canvas.hpp b/MetaUI/qt/include/meta_qt/widgets/array_canvas.hpp index 0322484..99e578c 100644 --- a/MetaUI/qt/include/meta_qt/widgets/array_canvas.hpp +++ b/MetaUI/qt/include/meta_qt/widgets/array_canvas.hpp @@ -21,6 +21,8 @@ class ArrayCanvas : public QWidget QWidget *parent = nullptr); QSize sizeHint() const override; + QSize minimumSizeHint() const override { return QSize(120, 120); } + int heightForWidth(int width) const override { return width; } void set_field_data(const std::vector &data); const std::vector &get_field_data() const; @@ -40,6 +42,7 @@ class ArrayCanvas : public QWidget void edit_ended(); protected: + void resizeEvent(QResizeEvent *event) override; void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; @@ -51,6 +54,7 @@ class ArrayCanvas : public QWidget private: void draw_at(const QPoint &pos, Qt::MouseButtons buttons); + QPoint field_position(const QPoint &pos) const; void update_geometry(); bool is_mouse_cursor_on_img() const; QColor colormap(float v) const; diff --git a/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp b/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp index 8620f41..c45a6db 100644 --- a/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp +++ b/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp @@ -153,9 +153,9 @@ class GradientPicker : public QWidget PresetGridWidget *preset_grid_ = nullptr; bool rebuild_pending_ = false; - static constexpr int SWATCH_W = 60; // each preset swatch width - static constexpr int SWATCH_H = 32; // each preset swatch height - static constexpr int TOOLBAR_H = 24; // toolbar row height + static constexpr int SWATCH_W = 72; // each preset swatch width + static constexpr int SWATCH_H = 36; // each preset swatch height + static constexpr int TOOLBAR_H = 28; // toolbar row height // Declared last so it disconnects before the members its callback touches // are destroyed. diff --git a/MetaUI/qt/include/meta_qt/widgets/points_canvas.hpp b/MetaUI/qt/include/meta_qt/widgets/points_canvas.hpp index 783d382..8917b3d 100644 --- a/MetaUI/qt/include/meta_qt/widgets/points_canvas.hpp +++ b/MetaUI/qt/include/meta_qt/widgets/points_canvas.hpp @@ -54,6 +54,9 @@ class PointsCanvas : public QWidget QWidget *parent = nullptr); void clear_all(); + QSize sizeHint() const override { return QSize(320, 320); } + QSize minimumSizeHint() const override { return QSize(120, 120); } + int heightForWidth(int width) const override { return width; } void randomize(int count); void load_csv(const QString &path); // x,y,z per line (z clamped to [0,1]) void set_points(const std::vector &new_points); @@ -67,10 +70,8 @@ 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 keyPressEvent(QKeyEvent *event) override; void resizeEvent(QResizeEvent *event) override; - void paintEvent(QPaintEvent *) override; void mousePressEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; @@ -93,6 +94,7 @@ class PointsCanvas : public QWidget float min_x_, max_x_, min_y_, max_y_, z_step_; int hovered_idx_ = -1; + QString order_input_; int drag_idx_ = -1; bool moved_during_drag_ = false; int hovered_segment_ = -1; @@ -108,4 +110,4 @@ class PointsCanvas : public QWidget static constexpr float POINT_R = 5.f; }; -} // namespace meta::qt \ No newline at end of file +} // namespace meta::qt diff --git a/MetaUI/qt/include/meta_qt/widgets/range_bar.hpp b/MetaUI/qt/include/meta_qt/widgets/range_bar.hpp index 3a597f8..30a15ba 100644 --- a/MetaUI/qt/include/meta_qt/widgets/range_bar.hpp +++ b/MetaUI/qt/include/meta_qt/widgets/range_bar.hpp @@ -7,6 +7,7 @@ #include #include +#include "meta_qt/ui/theme.hpp" namespace meta::qt { @@ -33,6 +34,7 @@ class RangeBar : public QWidget QWidget *parent = nullptr); void set_value(glm::vec2 v); + void set_theme(const Theme &theme) { theme_ = theme; industrial_ = true; update(); } void set_histogram(const std::vector &x, const std::vector &y); Q_SIGNALS: @@ -47,6 +49,8 @@ class RangeBar : public QWidget void leaveEvent(QEvent *) override; private: + Theme theme_; + bool industrial_ = false; enum class Handle { None, @@ -82,4 +86,4 @@ class RangeBar : public QWidget static constexpr int track_h_ = 8; // track height }; -} // namespace meta::qt \ No newline at end of file +} // namespace meta::qt diff --git a/MetaUI/qt/src/designs/industrial/check_row.cpp b/MetaUI/qt/src/designs/industrial/check_row.cpp index b0e2d76..da89c6f 100644 --- a/MetaUI/qt/src/designs/industrial/check_row.cpp +++ b/MetaUI/qt/src/designs/industrial/check_row.cpp @@ -20,6 +20,12 @@ CheckRow::CheckRow(Attribute &attr, { key_ = attr.name(); label_ = meta::common::label(attr); + if (label_.find('_') != std::string::npos) + { + QString readable = QString::fromStdString(label_).replace('_', ' '); + if (!readable.isEmpty()) readable[0] = readable[0].toUpper(); + label_ = readable.toStdString(); + } value_ = attr.value(); knob_ = value_ ? 1.0 : 0.0; diff --git a/MetaUI/qt/src/designs/industrial/industrial.cpp b/MetaUI/qt/src/designs/industrial/industrial.cpp index c1f72a9..7e76d8f 100644 --- a/MetaUI/qt/src/designs/industrial/industrial.cpp +++ b/MetaUI/qt/src/designs/industrial/industrial.cpp @@ -4,10 +4,22 @@ #include "meta_qt/designs/industrial/industrial.hpp" #include "meta_qt/designs/industrial/check_row.hpp" +#include "meta_qt/designs/industrial/editor_style.hpp" +#include "meta_qt/widgets/range_bar.hpp" +#ifdef META_ENABLE_ARRAY_TYPES +#include "meta/ext/array/array.hpp" +#include "meta_qt/widgets/array_canvas.hpp" +#include +#endif +#ifdef META_ENABLE_COLOR_GRADIENT_TYPES +#include "meta/ext/color_gradient/color_gradient.hpp" +#endif #include "meta_qt/designs/industrial/combo.hpp" #include "meta_qt/designs/industrial/int_slider.hpp" +#include "meta_qt/designs/industrial/linked_sliders.hpp" #include "meta_qt/designs/industrial/param_slider.hpp" #include "meta_qt/designs/industrial/section.hpp" +#include "meta_qt/designs/industrial/text_row.hpp" #include "meta_qt/designs/stock/stock.hpp" #include "meta_qt/ui/design_registry.hpp" #include "meta_qt/ui/theme.hpp" @@ -61,10 +73,57 @@ void register_design() registry.register_control(kDesignName, "ButtonGrid"); + // --- std::string text rows: 20 rows. Both flavours are the same control, + // ReadOnlyText simply never accepts typing. + registry.register_control(kDesignName, + "SingleLineText"); + registry.register_control(kDesignName, "ReadOnlyText"); + +#ifdef META_ENABLE_GLM_TYPES + // --- glm::vec2: 41 rows, all of them Spatial Frequency. Two rails and a + // link, sharing slider_chrome with the single-value rows above. + registry.register_control(kDesignName, + "LinkedSliders"); +#endif + // 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(); + const RowFactory editor = [](AbstractAttribute &attr, const RowContext &ctx, + QWidget *parent) -> MetaWidget * + { + auto *widget = DesignRegistry::instance().render(&attr, stock::kDesignName, ctx, parent); + if (!widget) return nullptr; +#ifdef META_ENABLE_ARRAY_TYPES + if (auto *canvas = widget->findChild()) + { + auto *layout = qobject_cast(widget->layout()); + layout->insertWidget(0, new QLabel(QString::fromStdString(meta::common::label(static_cast &>(attr))), widget)); + auto *hint = new QLabel(QObject::tr("Drag to paint · Right-drag to erase · Scroll to resize"), widget); + hint->setWordWrap(true); + layout->addWidget(hint); + canvas->setProperty("industrialEditor", true); + } +#endif + style_editor(widget, ctx.theme ? *ctx.theme : DesignRegistry::instance().theme(kDesignName)); + for (auto *range : widget->findChildren()) { + range->set_theme(ctx.theme ? *ctx.theme : DesignRegistry::instance().theme(kDesignName)); + range->setFixedHeight(60); + } + return widget; + }; +#ifdef META_ENABLE_GLM_TYPES + registry.add(kDesignName, typeid(glm::vec2), "RangeBar", editor); + registry.add(kDesignName, typeid(std::vector), "PathEditor", editor); + registry.add(kDesignName, typeid(std::vector), "PointsEditor", editor); +#endif +#ifdef META_ENABLE_ARRAY_TYPES + registry.add(kDesignName, typeid(meta::Array), kAnyWidgetType, editor); +#endif +#ifdef META_ENABLE_COLOR_GRADIENT_TYPES + registry.add(kDesignName, typeid(meta::ColorGradient), kAnyWidgetType, editor); +#endif registry.set_fallback(kDesignName, stock::kDesignName); } diff --git a/MetaUI/qt/src/designs/industrial/int_slider.cpp b/MetaUI/qt/src/designs/industrial/int_slider.cpp index 7289128..8185bfe 100644 --- a/MetaUI/qt/src/designs/industrial/int_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/int_slider.cpp @@ -55,7 +55,8 @@ IntSlider::IntSlider(Attribute &attr, max_ = std::numeric_limits::max(); } - value_ = std::clamp(attr.value(), min_, max_); + input_max_ = max_ == 64 ? std::numeric_limits::max() : max_; + value_ = std::clamp(attr.value(), min_, input_max_); norm_ = unbounded_ ? kRestNorm : to_norm(value_); setFixedHeight(theme().metrics.row_height); @@ -136,7 +137,7 @@ bool IntSlider::can_render(const Attribute &attr) void IntSlider::set(const int &value) { - value_ = std::clamp(value, min_, max_); + value_ = std::clamp(value, min_, input_max_); // Unbounded: the thumb encodes drag distance, not the value, so a sync from // the model leaves it where it rests. jump() also cancels a recentre still @@ -158,7 +159,7 @@ QSize IntSlider::sizeHint() const 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); + return std::clamp((qreal(value) - min_) / (qreal(max_) - min_), 0.0, 1.0); } int IntSlider::from_norm(qreal t) const @@ -397,7 +398,7 @@ void IntSlider::drag_by(int x, Qt::KeyboardModifiers modifiers) void IntSlider::commit_value(int value) { begin_edit(); - apply_value(std::clamp(value, min_, max_), !unbounded_); + apply_value(std::clamp(value, min_, input_max_), !unbounded_); // A bounded row ends its edit when the glide settles. An unbounded one has // no glide to wait on, so it ends here. diff --git a/MetaUI/qt/src/designs/industrial/linked_sliders.cpp b/MetaUI/qt/src/designs/industrial/linked_sliders.cpp new file mode 100644 index 0000000..cdabaa0 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/linked_sliders.cpp @@ -0,0 +1,140 @@ +/* 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. */ +#ifdef META_ENABLE_GLM_TYPES +#include "meta_qt/designs/industrial/linked_sliders.hpp" +#include +#include +#include +#include +namespace meta::qt::industrial +{ +LinkedSliders::LinkedSliders(Attribute &attr, + const RowContext &ctx, + QWidget *parent) + : Control(ctx, parent), axes_theme_(theme()), state_(&attr.state()) +{ + if (auto *flag = state_->try_value(meta::keys::state::locked_xy)) + linked_ = *flag; + axes_theme_.metrics.label_min_width = 18; + axes_theme_.metrics.label_max_width = 24; + axes_theme_.metrics.label_width_ratio = .06; + axes_theme_.ink_locked = theme().ink_secondary; + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(2); + auto *heading = new QHBoxLayout; + auto *label = new QLabel(QString::fromStdString(meta::common::label(attr))); + label->setFont(row_label_font()); + label->setStyleSheet( + QString("color: %1; background: transparent;").arg(theme().ink_primary.name())); + heading->addWidget(label, 1); + link_ = new QToolButton; + link_->setCheckable(true); + link_->setChecked(linked_); + link_->setFixedSize(58, 24); + link_->setFont(ui_font(11, true)); + link_->setAccessibleName("Link X and Y"); + link_->setToolTip("Link axes: changes to X also update Y"); + link_->setCursor(Qt::PointingHandCursor); + link_->setStyleSheet( + QString("QToolButton { color: %1; background: %2; border: 1px solid %3; " + "border-radius: 5px; } QToolButton:checked { border-color: %4; background: " + "%5; } QToolButton:hover { border-color: %4; }") + .arg(theme().ink_primary.name(), + theme().field.name(), + theme().field_border.name(), + theme().accent.name(), + theme().section_header_hover.name())); + heading->addWidget(link_); + layout->addLayout(heading); + for (int i = 0; i < 2; ++i) + { + auto &metadata = axes_[i].metadata(); + metadata.try_add(std::string(meta::keys::constraints::min), + meta::common::min(attr)); + metadata.try_add(std::string(meta::keys::constraints::max), + meta::common::max(attr)); + metadata.try_add(std::string(meta::keys::ui::label), std::string(i ? "Y" : "X")); + metadata.try_add(std::string(meta::keys::ui::format), std::string("{:.2f}")); + RowContext axis_ctx = ctx; + axis_ctx.theme = &axes_theme_; + if (ctx.default_value) + axis_ctx.default_value = + [get = ctx.default_value, key = attr.name(), i](const std::string &) -> std::any + { + const auto initial = get(key); + if (auto pair = std::any_cast(&initial)) + return (*pair)[i]; + return {}; + }; + sliders_[i] = new ParamSlider(axes_[i], axis_ctx, this); + layout->addWidget(sliders_[i]); + connect(sliders_[i], &ControlBase::edit_started, this, [this] { begin_edit(); }); + connect(sliders_[i], + &ControlBase::value_changed, + this, + [this, i] + { + value_[i] = sliders_[i]->get(); + if (linked_) + { + value_[1 - i] = value_[i]; + sliders_[1 - i]->set(value_[i]); + } + notify_value_changed(); + }); + connect(sliders_[i], &ControlBase::edit_ended, this, [this] { end_edit(); }); + } + connect(link_, + &QToolButton::toggled, + this, + [this](bool linked) + { + linked_ = linked; + state_->try_add(std::string(meta::keys::state::locked_xy), linked) + ->value() = linked; + if (linked && value_.y != value_.x) + { + begin_edit(); + value_.y = value_.x; + sliders_[1]->set(value_.y); + notify_value_changed(); + end_edit(); + } + on_state_changed(); + }); + set(attr.value()); + on_state_changed(); + setFixedHeight(28 + 2 * theme().metrics.row_height); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} +void LinkedSliders::set(const glm::vec2 &value) +{ + value_ = value; + if (auto *flag = state_->try_value(meta::keys::state::locked_xy)) + linked_ = *flag; + for (int i = 0; i < 2; ++i) + sliders_[i]->set(value_[i]); + const QSignalBlocker blocker(link_); + link_->setChecked(linked_); + on_state_changed(); +} +QSize LinkedSliders::sizeHint() const +{ + return QSize(280, 28 + 2 * theme().metrics.row_height); +} +void LinkedSliders::on_state_changed() +{ + if (!link_ || !sliders_[1]) + return; + link_->setText(linked_ ? "X = Y" : "X / Y"); + link_->setEnabled(!is_locked()); + for (int i = 0; i < 2; ++i) + { + sliders_[i]->set_locked(is_locked() || (i == 1 && linked_)); + sliders_[i]->set_modified(is_modified()); + } +} +} // namespace meta::qt::industrial +#endif diff --git a/MetaUI/qt/src/designs/industrial/param_slider.cpp b/MetaUI/qt/src/designs/industrial/param_slider.cpp index 358f6dd..128c685 100644 --- a/MetaUI/qt/src/designs/industrial/param_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/param_slider.cpp @@ -2,6 +2,7 @@ Public License. The full license is in the file LICENSE, distributed with this software. */ #include "meta_qt/designs/industrial/param_slider.hpp" +#include "meta_qt/ui/number_format.hpp" #include #include @@ -67,7 +68,8 @@ ParamSlider::ParamSlider(Attribute &attr, // no span to lay a mapping over in the first place. if (log_scale_ && (unbounded_ || min_ <= kLogFloor)) log_scale_ = false; - value_ = std::clamp(attr.value(), min_, max_); + input_max_ = max_ == 64 ? std::numeric_limits::max() : max_; + value_ = std::clamp(attr.value(), min_, input_max_); norm_ = unbounded_ ? kRestNorm : to_norm(value_); setFixedHeight(theme().metrics.row_height); @@ -114,7 +116,7 @@ ParamSlider::ParamSlider(Attribute &attr, notify_value_changed(); end_edit(); }); - glide_->jump(norm_); + { const QSignalBlocker blocker(glide_); glide_->jump(norm_); } field_ = new QLineEdit(this); field_->setAlignment(Qt::AlignRight | Qt::AlignVCenter); @@ -162,7 +164,7 @@ bool ParamSlider::can_render(const Attribute &attr) void ParamSlider::set(const float &value) { - const float clamped = std::clamp(value, min_, max_); + const float clamped = std::clamp(value, min_, input_max_); // Unbounded: the thumb encodes drag distance, not the value, so a sync from // the model leaves it where it rests. jump() also cancels a recentre still @@ -459,7 +461,7 @@ void ParamSlider::drag_by(int x, Qt::KeyboardModifiers modifiers) void ParamSlider::apply_value(float value) { - const float clamped = std::clamp(value, min_, max_); + const float clamped = std::clamp(value, min_, input_max_); const bool changed = clamped != value_; value_ = clamped; @@ -473,22 +475,25 @@ void ParamSlider::commit_value(float value) { begin_edit(); - const float clamped = std::clamp(value, min_, max_); + const float clamped = std::clamp(value, min_, input_max_); if (!unbounded_) { - glide_->to(to_norm(clamped)); // finished() commits and ends the edit - return; + // Typed numbers are authoritative, even where normalising a wide range + // cannot represent all their digits. Position the rail, then seat the value. + glide_->jump(to_norm(clamped)); } - // Nothing to glide towards: the thumb is already at rest and stays there. - apply_value(clamped); + value_ = clamped; + refresh_field(); + update(); + notify_value_changed(); end_edit(); } QString ParamSlider::format_value(float value) const { - return QString::number(value, 'f', decimals_); + return display_float(value); } void ParamSlider::refresh_field() diff --git a/MetaUI/qt/src/designs/industrial/section.cpp b/MetaUI/qt/src/designs/industrial/section.cpp index f9beaa8..7903867 100644 --- a/MetaUI/qt/src/designs/industrial/section.cpp +++ b/MetaUI/qt/src/designs/industrial/section.cpp @@ -138,6 +138,12 @@ int ClipBox::body_height() const { if (!body_) return 0; + if (body_->hasHeightForWidth()) + { + const int height = body_->heightForWidth(width()); + if (height >= 0) return height; + } + // 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(); diff --git a/MetaUI/qt/src/designs/industrial/text_row.cpp b/MetaUI/qt/src/designs/industrial/text_row.cpp new file mode 100644 index 0000000..4792a55 --- /dev/null +++ b/MetaUI/qt/src/designs/industrial/text_row.cpp @@ -0,0 +1,166 @@ +/* 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/text_row.hpp" + +#include + +#include +#include +#include + +#include "meta_qt/designs/industrial/slider_chrome.hpp" + +namespace meta::qt::industrial +{ + +TextRow::TextRow(Attribute &attr, const RowContext &ctx, QWidget *parent) + : Control(ctx, parent) +{ + label_ = meta::common::label(attr); + value_ = attr.value(); + read_only_ = meta::common::try_get(attr, meta::keys::ui::read_only, false) || + meta::common::widget_type(attr) == "ReadOnlyText"; + + setFixedHeight(theme().metrics.row_height); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + + field_ = new QLineEdit(this); + field_->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + field_->setFrame(false); + + // The host font rather than the mono face. A readout is a number, where + // aligned digits matter; this is prose, and prose in a mono face reads as a + // code editor. + field_->setFont(row_label_font()); + field_->installEventFilter(this); + + refresh_field(); + restyle_field(); + + connect(field_, + &QLineEdit::editingFinished, + this, + [this]() + { + const std::string typed = field_->text().toStdString(); + if (typed == value_) + return; + + begin_edit(); + value_ = typed; + notify_value_changed(); + end_edit(); + }); + + connect(field_, &QLineEdit::textEdited, this, [this]() { restyle_field(true); }); +} + +bool TextRow::can_render(const Attribute &) { return true; } + +void TextRow::set(const std::string &value) +{ + value_ = value; + refresh_field(); + update(); +} + +QSize TextRow::sizeHint() const +{ + return QSize(theme().metrics.label_min_width + 160, theme().metrics.row_height); +} + +// --- geometry + +QRect TextRow::label_rect() const +{ + const Metrics &m = theme().metrics; + + // Same formula the sliders use, so labels line up down the whole panel + // rather than every row type picking its own column. + const int label_width = int(std::clamp(width() * m.label_width_ratio, + m.label_min_width, + m.label_max_width)); + + return QRect(0, 0, label_width, height()); +} + +QRect TextRow::field_rect() const +{ + const Metrics &m = theme().metrics; + const int x = label_rect().width() + m.gap; + + return QRect(x, + (height() - m.value_field_height) / 2, + std::max(0, width() - x), + m.value_field_height); +} + +// --- painting + +void TextRow::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + + QFont label_font = row_label_font(); + painter.setFont(label_font); + painter.setPen(theme().state_ink(is_modified(), is_locked() || read_only_)); + painter.drawText( + label_rect(), + Qt::AlignLeft | Qt::AlignVCenter, + elide_label(QString::fromStdString(label_), label_font, label_rect().width())); +} + +void TextRow::resizeEvent(QResizeEvent *event) +{ + field_->setGeometry(field_rect()); + QWidget::resizeEvent(event); +} + +// --- state + +void TextRow::on_state_changed() +{ + restyle_field(field_ && field_->hasFocus()); + update(); +} + +bool TextRow::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); +} + +// --- helpers + +void TextRow::refresh_field() +{ + if (!field_ || field_->hasFocus()) + return; // never overwrite mid-typing + + const QSignalBlocker blocker(field_); + field_->setText(QString::fromStdString(value_)); +} + +void TextRow::restyle_field(bool editing) +{ + if (!field_) + return; + + const bool locked = is_locked() || read_only_; + + field_->setReadOnly(locked); + field_->setStyleSheet( + field_stylesheet(theme(), editing && !locked, is_modified(), locked)); +} + +} // namespace meta::qt::industrial diff --git a/MetaUI/qt/src/designs/stock/stock_glm.cpp b/MetaUI/qt/src/designs/stock/stock_glm.cpp index 4ce8ef1..e6da22e 100644 --- a/MetaUI/qt/src/designs/stock/stock_glm.cpp +++ b/MetaUI/qt/src/designs/stock/stock_glm.cpp @@ -523,6 +523,13 @@ MetaWidget *render_vec2(AbstractAttribute &abstract_attr, { toggle_btn->setText(active ? QObject::tr("On") : QObject::tr("Off")); + Q_EMIT widget->edit_started(); + // Publish the state before the value: value changes synchronously + // refresh the widget through the model subscription. + if (auto *p = attr.state().try_value(meta::keys::state::active)) + *p = active; + set_active(active); + if (active) { attr.set_from_any(lav); @@ -535,12 +542,6 @@ MetaWidget *render_vec2(AbstractAttribute &abstract_attr, 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(); }); diff --git a/MetaUI/qt/src/widgets/array_canvas.cpp b/MetaUI/qt/src/widgets/array_canvas.cpp index 8962732..681a89d 100644 --- a/MetaUI/qt/src/widgets/array_canvas.cpp +++ b/MetaUI/qt/src/widgets/array_canvas.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -25,6 +27,10 @@ ArrayCanvas::ArrayCanvas(const std::string &label, setFocusPolicy(Qt::StrongFocus); setMouseTracking(true); setAttribute(Qt::WA_Hover); + QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); + policy.setHeightForWidth(true); + setSizePolicy(policy); + setMinimumSize(120, 120); help_msg_ = "Array editor\n- Left-click: Paint\n- Right-click: Erase\n- Mousewheel: " @@ -37,9 +43,7 @@ ArrayCanvas::ArrayCanvas(const std::string &label, QSize ArrayCanvas::sizeHint() const { - Style style(this); - int gap = style.border_radius(); - return QSize(width_ + 2 * gap, height_ + 2 * gap); + return QSize(320, 320); } void ArrayCanvas::set_field_data(const std::vector &data) @@ -154,8 +158,6 @@ void ArrayCanvas::draw_at(const QPoint &pos, Qt::MouseButtons buttons) } } - update(); - Q_EMIT value_changed(); } QColor ArrayCanvas::colormap(float v) const @@ -176,15 +178,23 @@ void ArrayCanvas::update_geometry() Style style(this); int gap = style.border_radius(); - int canvas_width = width_ + 2 * gap; - int canvas_height = height_ + 2 * gap; + const int side = std::max(1, std::min(width(), height()) - 2 * gap); + rect_img_ = QRect((width() - side) / 2, (height() - side) / 2, side, side); + update(); +} - rect_img_ = QRect(QPoint(gap, gap), QSize(width_, height_)); +void ArrayCanvas::resizeEvent(QResizeEvent *event) +{ + if (height() != width()) + setFixedHeight(width()); + update_geometry(); + QWidget::resizeEvent(event); +} - setMinimumSize(canvas_width, canvas_height); - setMaximumSize(canvas_width, canvas_height); - setFixedSize(canvas_width, canvas_height); - update(); +QPoint ArrayCanvas::field_position(const QPoint &pos) const +{ + return QPoint(int(double(pos.x() - rect_img_.x()) * width_ / rect_img_.width()), + int(double(pos.y() - rect_img_.y()) * height_ / rect_img_.height())); } bool ArrayCanvas::event(QEvent *event) @@ -270,9 +280,13 @@ void ArrayCanvas::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton || event->button() == Qt::RightButton) { + if (!rect_img_.contains(event->position().toPoint())) return; is_drawing_ = true; - QPoint pos = event->position().toPoint() - rect_img_.topLeft(); + QPoint pos = field_position(event->position().toPoint()); + pos_previous_ = pos; draw_at(pos, event->buttons()); + update(); + Q_EMIT value_changed(); } } @@ -290,8 +304,17 @@ void ArrayCanvas::mouseMoveEvent(QMouseEvent *event) { if (is_drawing_) { - QPoint pos = event->position().toPoint() - rect_img_.topLeft(); - draw_at(pos, event->buttons()); + const QPoint pos = field_position(event->position().toPoint()); + const QPoint delta = pos - pos_previous_; + const int steps = std::max(1, int(std::ceil(std::hypot(delta.x(), delta.y()) / + std::max(1., brush_radius_ / 3.)))); + for (int i = 1; i <= steps; ++i) + draw_at(pos_previous_ + QPoint(qRound(double(delta.x()) * i / steps), + qRound(double(delta.y()) * i / steps)), + event->buttons()); + pos_previous_ = pos; + update(); + Q_EMIT value_changed(); } QWidget::mouseMoveEvent(event); } @@ -328,7 +351,10 @@ void ArrayCanvas::paintEvent(QPaintEvent *) : style.border_width(); // Background filled area - painter.fillRect(rect(), palette().color(QPalette::Base)); + QPainterPath outline; + outline.addRoundedRect(QRectF(rect()), radius, radius); + painter.setClipPath(outline); + painter.fillPath(outline, palette().color(QPalette::Base)); // Background image bool is_image = !bg_image_.isNull() && show_bg_image_; @@ -359,6 +385,7 @@ void ArrayCanvas::paintEvent(QPaintEvent *) } // Draw label + if (!property("industrialEditor").toBool()) { painter.setPen(palette().color(QPalette::Text)); painter.drawText(rect_img_, @@ -377,7 +404,9 @@ void ArrayCanvas::paintEvent(QPaintEvent *) } painter.setPen(pen); painter.setBrush(Qt::NoBrush); - painter.drawEllipse(mouse_pos, brush_radius_, brush_radius_); + painter.drawEllipse(QPointF(mouse_pos), + double(brush_radius_) * rect_img_.width() / width_, + double(brush_radius_) * rect_img_.height() / height_); // Info overlay QString txt; diff --git a/MetaUI/qt/src/widgets/gradient_picker.cpp b/MetaUI/qt/src/widgets/gradient_picker.cpp index 3d5eb73..64e0495 100644 --- a/MetaUI/qt/src/widgets/gradient_picker.cpp +++ b/MetaUI/qt/src/widgets/gradient_picker.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -360,6 +361,17 @@ class PresetGridWidget : public QWidget if (buttons_.empty()) return; const int cols = compute_cols(avail_w); + const int tile_width = std::max(swatch_w_, (avail_w - 4 - (cols - 1) * spacing_) / cols); + for (auto *button : buttons_) + { + button->setFixedWidth(tile_width); + const auto source = button->property("swatch_image").value(); + if (!source.isNull()) { + const QSize icon_size(tile_width - 6, swatch_h_ - 6); + button->setIcon(QIcon(source.scaled(icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation))); + button->setIconSize(icon_size); + } + } if (cols == current_cols_) { const int h = heightForWidth(avail_w); @@ -455,7 +467,7 @@ GradientPicker::GradientPicker(std::vector &stops, auto *main_layout = new QVBoxLayout(this); main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(4); + main_layout->setSpacing(10); // Gradient bar pinned at the top (fixed height, never scrolls) bar_widget_ = new GradientBarWidget(stops_, this); @@ -472,8 +484,10 @@ GradientPicker::GradientPicker(std::vector &stops, scroll_area_->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); scroll_area_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - preset_grid_ = new PresetGridWidget(SWATCH_W, SWATCH_H, 4, scroll_area_); + preset_grid_ = new PresetGridWidget(SWATCH_W, SWATCH_H, 6, scroll_area_); scroll_area_->setWidget(preset_grid_); + preset_grid_->setAutoFillBackground(false); + scroll_area_->viewport()->setAutoFillBackground(false); if (scroll_area_->viewport()) scroll_area_->viewport()->installEventFilter(this); @@ -506,7 +520,7 @@ GradientPicker::GradientPicker(std::vector &stops, QWidget *GradientPicker::build_toolbar() { auto *bar = new QWidget(this); - auto *layout = new QHBoxLayout(bar); + auto *layout = new QVBoxLayout(bar); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(4); @@ -522,7 +536,7 @@ QWidget *GradientPicker::build_toolbar() return button; }; - save_button_ = make_button(tr("Save..."), + save_button_ = make_button(tr("Save preset…"), tr("Save the current gradient to your library")); import_button_ = make_button(tr("Import..."), tr("Import gradients from JSON files")); @@ -559,12 +573,27 @@ QWidget *GradientPicker::build_toolbar() static_cast(index)); }); - layout->addWidget(save_button_); - layout->addWidget(import_button_); - layout->addWidget(export_button_); - layout->addStretch(1); - layout->addWidget(new QLabel(tr("Sort"), bar)); - layout->addWidget(sort_combo_); + auto *actions = new QHBoxLayout; + actions->setSpacing(6); + actions->addWidget(save_button_); + actions->addStretch(); + auto *files = make_button(tr("Files ▾"), tr("Import or export gradients")); + auto *menu = new QMenu(files); + menu->addAction(tr("Import…"), import_button_, &QToolButton::click); + auto *export_action = menu->addAction(tr("Export library…"), export_button_, &QToolButton::click); + connect(menu, &QMenu::aboutToShow, this, [this, export_action] { export_action->setEnabled(export_button_->isEnabled()); }); + files->setMenu(menu); + files->setPopupMode(QToolButton::InstantPopup); + import_button_->hide(); + export_button_->hide(); + actions->addWidget(files); + layout->addLayout(actions); + auto *sorting = new QHBoxLayout; + sorting->addWidget(new QLabel(tr("Presets"), bar)); + sorting->addStretch(); + sort_combo_->setFixedWidth(120); + sorting->addWidget(sort_combo_); + layout->addLayout(sorting); return bar; } @@ -582,6 +611,12 @@ void GradientPicker::set_presets(const std::vector &presets) void GradientPicker::update_bar() { if (bar_widget_) bar_widget_->update(); + for (auto *button : findChildren()) + { + const auto index = button->property("preset_index"); + if (index.isValid() && index.toInt() < int(entries_.size())) + button->setChecked(entries_[index.toInt()].preset.stops == stops_); + } } void GradientPicker::schedule_rebuild() @@ -675,8 +710,12 @@ void GradientPicker::rebuild_entries() QPixmap GradientPicker::make_swatch(const Entry &entry, bool favorite) const { QPixmap pix(SWATCH_W, SWATCH_H); + pix.fill(Qt::transparent); QPainter pp(&pix); pp.setRenderHint(QPainter::Antialiasing); + QPainterPath outline; + outline.addRoundedRect(QRectF(pix.rect()), 4, 4); + pp.setClipPath(outline); QLinearGradient grad(0, 0, pix.width(), 0); for (const auto &s : entry.preset.stops) @@ -684,11 +723,17 @@ QPixmap GradientPicker::make_swatch(const Entry &entry, bool favorite) const pp.fillRect(pix.rect(), grad); // Name overlay + const bool generated_name = entry.preset.name.size() == 6 && + std::all_of(entry.preset.name.begin(), entry.preset.name.end(), + [](unsigned char c) { return std::isxdigit(c); }); + if (!generated_name) { + pp.fillRect(QRect(0, pix.height() - 13, pix.width(), 13), QColor(0, 0, 0, 150)); pp.setPen(Qt::white); pp.setFont(QFont(pp.font().family(), 7)); pp.drawText(pix.rect().adjusted(2, 0, -2, 0), Qt::AlignBottom | Qt::AlignHCenter, - QString::fromStdString(entry.preset.name)); + QString::fromStdString(entry.preset.name)); + } // Favourite star (top-left) and library marker (top-right) if (favorite) draw_star(pp, QPointF(8, 8), 5.5); @@ -742,7 +787,9 @@ void GradientPicker::rebuild_preset_grid() auto *btn = new QPushButton(preset_grid_); btn->setFixedSize(SWATCH_W, SWATCH_H); btn->setFlat(true); - btn->setIcon(QIcon(make_swatch(entry, favorite))); + const auto swatch = make_swatch(entry, favorite); + btn->setProperty("swatch_image", swatch); + btn->setIcon(QIcon(swatch)); btn->setIconSize(QSize(SWATCH_W, SWATCH_H)); btn->setToolTip( QString("%1\n%2, %3 %4") @@ -751,6 +798,9 @@ void GradientPicker::rebuild_preset_grid() .arg(tr("stops"))); btn->setCursor(Qt::PointingHandCursor); btn->setProperty("preset_name", name); + btn->setProperty("preset_index", int(i)); + btn->setCheckable(true); + btn->setChecked(entry.preset.stops == stops_); btn->setProperty("preset_user", entry.user); btn->setContextMenuPolicy(Qt::CustomContextMenu); @@ -789,6 +839,7 @@ void GradientPicker::apply_stops(const std::vector &stops) } Q_EMIT value_changed(); Q_EMIT edit_ended(); + update_bar(); } std::vector GradientPicker::host_names() const @@ -997,14 +1048,14 @@ bool GradientPicker::eventFilter(QObject *watched, QEvent *event) QSize GradientPicker::sizeHint() const { - const int top_h = GradientBarWidget::TOTAL_H + 4 + TOOLBAR_H; - const int preset_h = entries_.empty() ? 0 : (SWATCH_H + 4) * 3 + 8; + const int top_h = GradientBarWidget::TOTAL_H + 28 + 2 * TOOLBAR_H; + const int preset_h = entries_.empty() ? 0 : (SWATCH_H + 6) * 3 + 8; return {300, top_h + (entries_.empty() ? 0 : 4 + preset_h)}; } QSize GradientPicker::minimumSizeHint() const { - const int top_h = GradientBarWidget::TOTAL_H + 4 + TOOLBAR_H; + const int top_h = GradientBarWidget::TOTAL_H + 28 + 2 * TOOLBAR_H; const int preset_h = entries_.empty() ? 0 : SWATCH_H + 8; return {160, top_h + (entries_.empty() ? 0 : 4 + preset_h)}; } diff --git a/MetaUI/qt/src/widgets/points_canvas.cpp b/MetaUI/qt/src/widgets/points_canvas.cpp index 0146eb7..fc8c692 100644 --- a/MetaUI/qt/src/widgets/points_canvas.cpp +++ b/MetaUI/qt/src/widgets/points_canvas.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include "meta_qt/ui/number_format.hpp" #include #include #include @@ -20,6 +22,47 @@ namespace meta::qt { +void PointsCanvas::resizeEvent(QResizeEvent *event) +{ + if (height() != width()) + setFixedHeight(width()); + QWidget::resizeEvent(event); +} + +void PointsCanvas::keyPressEvent(QKeyEvent *event) +{ + if (mode_ != Mode::Path || hovered_idx_ < 0 || + hovered_idx_ >= int(points_.size()) || drag_idx_ >= 0) + { + QWidget::keyPressEvent(event); + return; + } + if (event->key() >= Qt::Key_0 && event->key() <= Qt::Key_9) + { + if (order_input_.size() < 9) order_input_ += event->text(); + } + else if (event->key() == Qt::Key_Backspace) order_input_.chop(1); + else if (event->key() == Qt::Key_Escape) order_input_.clear(); + else if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) + { + bool valid = false; + const int destination = order_input_.toInt(&valid) - 1; + if (valid && destination >= 0 && destination < int(points_.size())) + { + const auto point = points_[hovered_idx_]; + points_.erase(points_.begin() + hovered_idx_); + points_.insert(points_.begin() + destination, point); + hovered_idx_ = destination; + order_input_.clear(); + Q_EMIT points_changed(); + Q_EMIT drag_ended(); + } + } + else { QWidget::keyPressEvent(event); return; } + event->accept(); + update(); +} + PointsCanvas::PointsCanvas(std::vector &points, float min_x, float max_x, @@ -39,26 +82,20 @@ PointsCanvas::PointsCanvas(std::vector &points, mode_(mode), closed_(closed) { - setMinimumSize(200, 200); - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + setMinimumSize(120, 120); + QSizePolicy policy(QSizePolicy::Expanding, QSizePolicy::Preferred); + policy.setHeightForWidth(true); + setSizePolicy(policy); setMouseTracking(true); + setFocusPolicy(Qt::StrongFocus); + setToolTip(tr("Hover a point and scroll to change its height. For paths, type its new position and press Enter. Escape cancels.")); 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); + const int side = std::max(1, std::min(width(), height()) - 2 * PAD); + return QRect((width() - side) / 2, (height() - side) / 2, side, side); } glm::vec2 PointsCanvas::canvas_to_value(const QPoint &p) const @@ -160,6 +197,11 @@ void PointsCanvas::mouseMoveEvent(QMouseEvent *e) const int prev_pt = hovered_idx_; const int prev_seg = hovered_segment_; hovered_idx_ = hit_test(e->pos()); + if (hovered_idx_ != prev_pt) + { + order_input_.clear(); + if (hovered_idx_ >= 0) setFocus(Qt::MouseFocusReason); + } // In Path mode, also track the nearest segment for insert-on-click // feedback. Only highlight a segment when NOT hovering an existing point. @@ -254,7 +296,9 @@ void PointsCanvas::paintEvent(QPaintEvent *) const QRect r = canvas_rect(); // Background - p.fillRect(rect(), palette().color(QPalette::Base)); + p.setPen(Qt::NoPen); + p.setBrush(palette().color(QPalette::Base)); + p.drawRoundedRect(rect().adjusted(1, 1, -1, -1), 8, 8); // Background image if (!this->bg_pixels_.empty() && this->bg_w_ > 0 && this->bg_h_ > 0) @@ -274,7 +318,9 @@ void PointsCanvas::paintEvent(QPaintEvent *) // Grid { - QPen gp(palette().color(QPalette::Mid), 1, Qt::DotLine); + QColor grid = palette().color(QPalette::Mid); + grid.setAlpha(100); + QPen gp(grid, 1, Qt::DotLine); p.setPen(gp); constexpr int div = 4; for (int i = 1; i < div; ++i) @@ -325,7 +371,7 @@ void PointsCanvas::paintEvent(QPaintEvent *) { const QPoint cp = value_to_canvas(points_[i].x, points_[i].y); p.drawText(cp + QPoint(int(POINT_R) + 3, -int(POINT_R)), - QString::number(i)); + QString::number(i + 1)); } } @@ -352,15 +398,18 @@ void PointsCanvas::paintEvent(QPaintEvent *) { p.setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); p.setPen(palette().color(QPalette::Text)); - p.drawText(cp + QPoint(int(POINT_R) + 3, 4), - QString::number(double(pt.z), 'f', 2)); + const QString info = order_input_.isEmpty() + ? tr("Point %1 · Height %2").arg(i + 1).arg(display_float(pt.z)) + : tr("Move to %1 · Enter to apply").arg(order_input_); + p.fillRect(r.adjusted(0, r.height() - 30, 0, 0), palette().color(QPalette::Base)); + p.drawText(r.adjusted(6, 0, -6, -10), Qt::AlignLeft | Qt::AlignBottom, info); } } // Point count p.setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); p.setPen(palette().color(QPalette::PlaceholderText)); - p.drawText(r.adjusted(4, 0, 0, -3), + if (hovered_idx_ < 0 && drag_idx_ < 0) p.drawText(r.adjusted(4, 0, 0, -3), Qt::AlignLeft | Qt::AlignBottom, QString("%1 pt%2") .arg(points_.size()) @@ -496,6 +545,8 @@ void PointsCanvas::wheelEvent(QWheelEvent *e) QColor PointsCanvas::z_to_color(float z) const { + if (property("industrialEditor").toBool()) + return palette().color(QPalette::Highlight).lighter(70 + int(60 * std::clamp(z, 0.f, 1.f))); // Simple blue(0) → cyan → green → yellow → red(1) heatmap. z = std::clamp(z, 0.f, 1.f); float r, g, b; diff --git a/MetaUI/qt/src/widgets/range_bar.cpp b/MetaUI/qt/src/widgets/range_bar.cpp index 6783a5b..7141845 100644 --- a/MetaUI/qt/src/widgets/range_bar.cpp +++ b/MetaUI/qt/src/widgets/range_bar.cpp @@ -8,6 +8,9 @@ #include #include #include +#include +#include "meta_qt/ui/number_format.hpp" +#include "meta_qt/ui/theme.hpp" #include "meta_qt/widgets/range_bar.hpp" @@ -164,19 +167,21 @@ void RangeBar::paintEvent(QPaintEvent *) p.setRenderHint(QPainter::Antialiasing); const QRect tr = track_rect(); - const int lx = value_to_canvas(value_.x); - const int hx = value_to_canvas(value_.y); + const float lo = isEnabled() ? value_.x : domain_min_; + const float hi = isEnabled() ? value_.y : domain_max_; + const int lx = value_to_canvas(lo); + const int hx = value_to_canvas(hi); // Track background p.setPen(Qt::NoPen); - p.setBrush(palette().color(QPalette::Mid)); + p.setBrush(industrial_ ? theme_.rail_well : palette().color(QPalette::Mid)); p.drawRoundedRect(tr, 3, 3); // Filled section between handles if (hx > lx) { QRect filled(lx, tr.top(), hx - lx, tr.height()); - p.setBrush(palette().color(QPalette::Highlight).darker(110)); + p.setBrush(industrial_ ? theme_.rail_fill("", !isEnabled()) : palette().color(QPalette::Highlight).darker(110)); p.drawRect(filled); } @@ -270,8 +275,25 @@ void RangeBar::paintEvent(QPaintEvent *) // outlined with the text color so it stays visible on the track. auto draw_handle = [&](int x, bool hovered, bool dragged) { + if (industrial_) + { + const auto &m = theme_.metrics; + const QRectF thumb(x - m.thumb_width / 2., tr.center().y() - m.thumb_height / 2., m.thumb_width, m.thumb_height); + QLinearGradient metal(thumb.topLeft(), thumb.bottomLeft()); + metal.setColorAt(0, theme_.thumb_top); + metal.setColorAt(1, theme_.thumb_bottom); + p.setOpacity(isEnabled() ? 1. : theme_.locked_thumb_alpha); + p.setPen(QPen(theme_.thumb_border, 1)); + p.setBrush(metal); + p.drawRoundedRect(thumb.adjusted(.5, .5, -.5, -.5), m.radius, m.radius); + p.setPen(Qt::NoPen); + p.setBrush(theme_.thumb_grip); + p.drawRect(QRectF(thumb.center().x(), thumb.center().y() - 3, 2, 8)); + p.setOpacity(1.); + return; + } const QRect hr(x - handle_w_, tr.top() - 3, handle_w_ * 2, tr.height() + 6); - p.setPen(QPen(palette().color(QPalette::Text), 1)); + p.setPen(QPen(palette().color(QPalette::Dark), 1)); p.setBrush(dragged ? palette().color(QPalette::Highlight) : hovered ? palette().color(QPalette::Light) : palette().color(QPalette::Button)); @@ -285,20 +307,20 @@ void RangeBar::paintEvent(QPaintEvent *) // Labels: low value left of low handle, high value right of high handle, // span in the center of the filled section. - p.setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + p.setFont(mono_font(12)); p.setPen(palette().color(QPalette::Text)); - const QString lo_txt = QString::number(double(value_.x), 'f', decimals_); - const QString hi_txt = QString::number(double(value_.y), 'f', decimals_); + const QString lo_txt = display_float(lo); + const QString hi_txt = display_float(hi); // Low label — left-aligned below the low handle - p.drawText(QRect(tr.left(), tr.bottom() + 3, (lx - tr.left()) * 2, 16), + p.drawText(QRect(tr.left(), tr.bottom() + 5, tr.width() / 2, 20), Qt::AlignLeft | Qt::AlignTop, lo_txt); // High label — right-aligned below the high handle p.drawText( - QRect(hx - (tr.right() - hx), tr.bottom() + 3, (tr.right() - hx) * 2, 16), + QRect(tr.center().x(), tr.bottom() + 5, tr.width() / 2, 20), Qt::AlignRight | Qt::AlignTop, hi_txt); } @@ -306,7 +328,7 @@ void RangeBar::paintEvent(QPaintEvent *) void RangeBar::set_value(glm::vec2 v) { value_ = v; - clamp_and_order(); + if (isEnabled()) clamp_and_order(); update(); } @@ -323,7 +345,8 @@ QRect RangeBar::track_rect() const { const int cy = height() / 2 - 4; // slight upward bias to leave room for labels - return QRect(pad_h_, cy - track_h_ / 2, width() - 2 * pad_h_, track_h_); + const int h = industrial_ ? theme_.metrics.rail_height : track_h_; + return QRect(pad_h_, cy - h / 2, width() - 2 * pad_h_, h); } int RangeBar::value_to_canvas(float v) const diff --git a/tests/test_qt/test_editor_controls/CMakeLists.txt b/tests/test_qt/test_editor_controls/CMakeLists.txt new file mode 100644 index 0000000..73e90b1 --- /dev/null +++ b/tests/test_qt/test_editor_controls/CMakeLists.txt @@ -0,0 +1,2 @@ +add_executable(test_editor_controls main.cpp) +target_link_libraries(test_editor_controls meta_qt) diff --git a/tests/test_qt/test_editor_controls/main.cpp b/tests/test_qt/test_editor_controls/main.cpp new file mode 100644 index 0000000..5cc3f6d --- /dev/null +++ b/tests/test_qt/test_editor_controls/main.cpp @@ -0,0 +1,158 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "meta_qt/ui/design_registry.hpp" +#include "meta_qt/ui/number_format.hpp" +#include "meta_qt/designs/industrial/industrial.hpp" +#include "meta_qt/designs/industrial/linked_sliders.hpp" +#include "meta_qt/designs/industrial/param_slider.hpp" +#include "meta_qt/designs/industrial/int_slider.hpp" +#include "meta_qt/designs/industrial/section.hpp" +#include "meta_qt/widgets/array_canvas.hpp" +#include "meta_qt/widgets/points_canvas.hpp" +#include "meta_qt/widgets/range_bar.hpp" +#include "meta/ext/array/array.hpp" +#include "meta/ext/color_gradient/color_gradient.hpp" +#include "meta/ext/color_gradient/gradient_library.hpp" +using namespace meta::qt; +namespace { +int failures=0; +void check(bool ok, const char *message) { if (!ok) { ++failures; std::cerr << message << '\n'; } } +void flush() { for(int i=0;i<8;++i) { QApplication::sendPostedEvents(); QApplication::processEvents(); } } +template void describe(meta::Attribute &a,const char *kind,const char *label) { + a.metadata().add(meta::keys::ui::widget_type,std::string(kind)); + a.metadata().add(meta::keys::ui::label,std::string(label)); +} +void mouse(QWidget *w,QEvent::Type type,QPoint pos) { + QMouseEvent event(type,QPointF(pos),QPointF(w->mapToGlobal(pos)),Qt::LeftButton,Qt::LeftButton,Qt::NoModifier); + QApplication::sendEvent(w,&event); +} +} +int main(int argc,char **argv) { + QApplication app(argc,argv); + Theme theme; + theme.accent = QColor("#5f85ab"); + QPalette palette = app.palette(); + palette.setColor(QPalette::Window, theme.section_surface); + palette.setColor(QPalette::WindowText, theme.ink_primary); + app.setPalette(palette); + RowContext ctx; ctx.theme=&theme; + industrial::register_design(); + auto ®istry=DesignRegistry::instance(); + { + meta::Attribute scalar("scalar",512.f); + describe(scalar,"Slider","Scalar"); + scalar.metadata().add(meta::keys::constraints::min,0.f); + scalar.metadata().add(meta::keys::constraints::max,64.f); + industrial::ParamSlider slider(scalar,ctx); + check(slider.get()==512.f,"initial float above drag range was lost"); + slider.set(1024.f); + check(slider.get()==1024.f,"float above drag range was lost on refresh"); + auto *field=slider.findChild(); + field->setText("512"); QMetaObject::invokeMethod(field,"editingFinished"); + check(slider.get()==512.f,"typed float capped at drag limit"); + slider.resize(400,slider.height()); slider.show(); flush(); + mouse(&slider,QEvent::MouseButtonPress,QPoint(295,slider.height()/2)); + mouse(&slider,QEvent::MouseMove,QPoint(800,slider.height()/2)); + mouse(&slider,QEvent::MouseButtonRelease,QPoint(800,slider.height()/2)); + check(slider.get()<=64.f,"float drag exceeded 64"); + meta::Attribute integer("integer",512); + describe(integer,"Slider","Integer"); + integer.metadata().add(meta::keys::constraints::min,0); + integer.metadata().add(meta::keys::constraints::max,64); + industrial::IntSlider ints(integer,ctx); + check(ints.get()==512,"initial integer above drag range was lost"); + ints.set(1024); check(ints.get()==1024,"integer refresh capped at drag limit"); + auto *int_field=ints.findChild(); + int_field->setText("512"); QMetaObject::invokeMethod(int_field,"editingFinished"); + check(ints.get()==512,"typed integer capped at drag limit"); + } + check(display_float(.001f)=="0.001", "small float displayed as zero"); + check(display_float(-.0001f)=="-0.0001", "negative small float lost precision"); + check(display_float(2.f)=="2.00", "ordinary float must retain two decimals"); + check(display_float(.00125f)=="0.00125", "small fractional detail lost"); + for(bool bounded:{false,true}) { + meta::Attribute frequency("frequency",glm::vec2(4,5)); + describe(frequency,"LinkedSliders","Spatial Frequency"); + if(bounded) { frequency.metadata().add(meta::keys::constraints::min,0.f); frequency.metadata().add(meta::keys::constraints::max,100.f); } + std::unique_ptr row(registry.render(&frequency,"industrial",ctx)); + check(row->findChild()!=nullptr,"frequency fell back to stock"); + auto fields=row->findChildren(); + check(fields.size()==2,"linked pair must expose two fields"); + fields[0]->setText("0.001"); QMetaObject::invokeMethod(fields[0],"editingFinished"); + check(frequency.value().x==.001f,"typed float not committed exactly"); + auto *link=row->findChild(); link->click(); + check(frequency.value().y==frequency.value().x,"link must copy X to Y"); + fields[0]->setText("0.0001"); QMetaObject::invokeMethod(fields[0],"editingFinished"); + check(frequency.value().x==.0001f && frequency.value().y==.0001f,"linked precision lost"); + link->click(); fields[1]->setText("0.002"); QMetaObject::invokeMethod(fields[1],"editingFinished"); + check(frequency.value().x==.0001f && frequency.value().y==.002f,"unlinked axes not independent"); + } + meta::Attribute> path("path",{{.15f,.3f,.7f},{.7f,.7f,1.f},{.75f,.2f,.5f}}); + describe(path,"PathEditor","Path"); + meta::Attribute brush("brush",meta::Array{{256,256},std::vector(256*256,0)}); + describe(brush,"ArrayEditor","Brush"); + brush.metadata().add(meta::keys::ui::width,256); brush.metadata().add(meta::keys::ui::height,256); + meta::Attribute frequency("frequency",glm::vec2(4)); describe(frequency,"LinkedSliders","Spatial Frequency"); + frequency.state().add(meta::keys::state::locked_xy,true); + meta::Attribute range("range",glm::vec2(.2f,.8f)); describe(range,"RangeBar","Remap Range"); + range.metadata().add(meta::keys::constraints::min,0.f); range.metadata().add(meta::keys::constraints::max,1.f); + range.state().add(meta::keys::state::active,true); + meta::Attribute gradient("gradient",meta::ColorGradient{}); describe(gradient,"GradientEditor","Gradient"); + meta::GradientPresets presets; + for(int i=0;i<20;++i) presets.presets.push_back({"Preset "+std::to_string(i), {{0,{0.f,float(i)/20,.2f,1}}, {1,{1,1,1,1}}}}); + gradient.metadata().add(meta::keys::ui::presets, presets); + meta::GradientLibrary::instance().set_path(std::filesystem::path(QDir::tempPath().toStdString())/"hesiod-editor-check-gradients.json"); + QScrollArea scroll; scroll.setAttribute(Qt::WA_DontShowOnScreen); scroll.setWidgetResizable(true); + auto *page=new QWidget; auto *layout=new QVBoxLayout(page); layout->setContentsMargins(0,0,0,0); layout->setSpacing(2); layout->setAlignment(Qt::AlignTop); + std::vector rows; + for(auto *attr:std::vector{&frequency,&path,&brush,&range,&gradient}) { + auto *section=new industrial::Section("Parameters",theme); + auto *row=registry.render(attr,"industrial",ctx); + rows.push_back(row); section->content_layout->addWidget(row); section->set_expanded(true); layout->addWidget(section); + } + scroll.setWidget(page); scroll.resize(440,720); scroll.show(); flush(); + auto *points=page->findChild(); auto *paint=page->findChild(); + { + const auto original = path.value(); + const int side=points->width()-20; + mouse(points,QEvent::MouseMove,QPoint(10+int(.15f*side),points->height()-11-int(.3f*side))); + QKeyEvent digit(QEvent::KeyPress,Qt::Key_3,Qt::NoModifier,"3"); + QApplication::sendEvent(points,&digit); + QKeyEvent enter(QEvent::KeyPress,Qt::Key_Return,Qt::NoModifier); + QApplication::sendEvent(points,&enter); + check(path.value()[2]==original[0] && path.value()[0]==original[1],"keyboard path reorder failed or changed point data"); + } + for(int width:{240,360,520,360,240,520}) { + scroll.resize(width,720); flush(); + std::cout << "width="<width()==paint->height(),"Brush canvas is not square"); + } + const QPoint begin(paint->width()/4,paint->height()/2), end(paint->width()*3/4,paint->height()/2); + mouse(paint,QEvent::MouseButtonPress,begin); mouse(paint,QEvent::MouseMove,end); mouse(paint,QEvent::MouseButtonRelease,end); + check(paint->get_field_data()[128*256+128]>0,"fast brush stroke has a gap"); + check(brush.value().shape==glm::ivec2(256),"display resize changed paint resolution"); + auto buttons=rows[3]->findChildren(); + QPushButton *toggle=nullptr; + for(auto *button:buttons) if(button->isCheckable()) toggle=button; + toggle->click(); + check(!rows[3]->findChild()->isEnabled(),"range toggle did not disable range"); + toggle->click(); + check(range.value()==glm::vec2(.2f,.8f),"range toggle lost saved endpoints"); + if(argc>1) { + scroll.resize(440,720); flush(); + QDir out(QString::fromLocal8Bit(argv[1])); out.mkpath("."); + int i=0; for(auto *row:rows) row->grab().save(out.filePath(QString("editor-%1.png").arg(i++))); + } + std::cout << "editor checks: failures="<