From 6cc4af8909aeadacada2340b96d6cb7c45edcf1c Mon Sep 17 00:00:00 2001 From: Leonhardmaster2 Date: Sun, 6 Sep 2026 16:51:30 +0200 Subject: [PATCH] feat(qt): industrial rows for the remaining attribute types Carries on from #54. The design covered floats, ints, bools and the two dropdowns, about 91% of the rows in a Hesiod panel. Everything else fell through to stock, so a panel with a wavenumber or a path in it still had foreign looking rows in the middle of it. New rows: - LinkedSliders for glm::vec2, the 41 Spatial Frequency rows. A heading with a link toggle over two ParamSlider axes, so the rails are literally the same control as the single value rows rather than a copy of them that can drift. The link persists on the attribute state under the key stock already uses, so it survives a selection change. - TextRow for std::string, the 20 SingleLineText and ReadOnlyText rows. Label in the sliders' column and a field running to the right edge, since a text row has no rail to leave room for. Reworked to match, rather than left to stock: - The Path and Brush canvases were stretching to whatever the panel gave them, which on a docked panel meant a 2:3 box for something that represents a square domain. Both are square at every width now. - Range bars draw the same rail and thumb as a normal slider instead of the older two handle renderer, which is why remap looked like it came from a different application. - The gradient picker gets compact actions behind a Files menu, cleaner swatches with the ids moved to tooltips, and a responsive preset grid. - Shared editor chrome moved into editor_style.hpp so the canvases and their button strips stop each inventing their own. Two behaviour fixes that came out of using it: - A slider showed 2 decimals whatever you typed, so entering 0.001 read back as 0.00 and looked like the value had been thrown away. The readout now widens to fit what the value actually needs. - Typing is no longer held to the drag range. Dragging still stops where it did, but a parameter whose rail ends at 64 accepts a typed 512 when the attribute permits it, which is what the rail limit was always meant to mean. Path points can be reordered from the keyboard: hover one, type its position, enter. Positions start at 1 and escape cancels. There was no way to do it at all before. test_editor_controls covers the square canvases across three panel widths. The section and combo suites still pass. --- .../designs/industrial/editor_style.hpp | 89 ++++++++++ .../meta_qt/designs/industrial/int_slider.hpp | 1 + .../designs/industrial/linked_sliders.hpp | 37 ++++ .../designs/industrial/param_slider.hpp | 1 + .../meta_qt/designs/industrial/text_row.hpp | 74 ++++++++ .../qt/include/meta_qt/ui/number_format.hpp | 27 +++ .../include/meta_qt/widgets/array_canvas.hpp | 4 + .../meta_qt/widgets/gradient_picker.hpp | 6 +- .../include/meta_qt/widgets/points_canvas.hpp | 10 +- .../qt/include/meta_qt/widgets/range_bar.hpp | 6 +- .../qt/src/designs/industrial/check_row.cpp | 6 + .../qt/src/designs/industrial/industrial.cpp | 59 +++++++ .../qt/src/designs/industrial/int_slider.cpp | 9 +- .../src/designs/industrial/linked_sliders.cpp | 140 +++++++++++++++ .../src/designs/industrial/param_slider.cpp | 25 +-- MetaUI/qt/src/designs/industrial/section.cpp | 6 + MetaUI/qt/src/designs/industrial/text_row.cpp | 166 ++++++++++++++++++ MetaUI/qt/src/designs/stock/stock_glm.cpp | 13 +- MetaUI/qt/src/widgets/array_canvas.cpp | 63 +++++-- MetaUI/qt/src/widgets/gradient_picker.cpp | 81 +++++++-- MetaUI/qt/src/widgets/points_canvas.cpp | 91 +++++++--- MetaUI/qt/src/widgets/range_bar.cpp | 47 +++-- .../test_editor_controls/CMakeLists.txt | 2 + tests/test_qt/test_editor_controls/main.cpp | 158 +++++++++++++++++ 24 files changed, 1029 insertions(+), 92 deletions(-) create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/editor_style.hpp create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/linked_sliders.hpp create mode 100644 MetaUI/qt/include/meta_qt/designs/industrial/text_row.hpp create mode 100644 MetaUI/qt/include/meta_qt/ui/number_format.hpp create mode 100644 MetaUI/qt/src/designs/industrial/linked_sliders.cpp create mode 100644 MetaUI/qt/src/designs/industrial/text_row.cpp create mode 100644 tests/test_qt/test_editor_controls/CMakeLists.txt create mode 100644 tests/test_qt/test_editor_controls/main.cpp 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="<