From c76bf5dd7ee2fdfbf646c68eab4723904a80209f Mon Sep 17 00:00:00 2001 From: Leonhardmaster2 Date: Sun, 6 Sep 2026 00:08:58 +0200 Subject: [PATCH 1/3] feat(qt): give the industrial sliders an unbounded mode The industrial sliders used to decline a range bounded by FLT_MAX or INT_MAX and let the row fall through to stock. That fixed a real bug, a rail cannot represent a range with no limits, but it hit 197 rows and 86 of those are Seed, which sits near the top of nearly every node. So most panels ended up with a stock row among the industrial ones. They now handle those rows themselves. The thumb rests at the centre of the rail, follows the cursor while dragging and eases back to centre on release, and the value moves by how far you dragged rather than by where the cursor is. Rates match stock so a row feels the same either way, 200 pixels per unit for float and 4 pixels per step for int, with ctrl fine and shift coarse. There is no fill, since there is no proportion to fill, and a centre tick marks the rest position while a drag is running. Measuring from the value at the press rather than accumulating per event means a drag out and back lands exactly where it started. Int rows widen to 64 bits before clamping, because Seed lives in [0, INT_MAX] and the arithmetic would otherwise overflow near the top of it. can_render still requires both constraint keys, it just no longer screens their values. --- .../meta_qt/designs/industrial/int_slider.hpp | 33 +++- .../designs/industrial/param_slider.hpp | 40 +++- .../designs/industrial/slider_chrome.hpp | 33 +++- .../qt/src/designs/industrial/int_slider.cpp | 171 ++++++++++++++-- .../src/designs/industrial/param_slider.cpp | 183 ++++++++++++++++-- .../src/designs/industrial/slider_chrome.cpp | 18 +- 6 files changed, 428 insertions(+), 50 deletions(-) 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 57621d4..6148b15 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/int_slider.hpp @@ -35,7 +35,13 @@ class IntSlider : public Control const RowContext &ctx, QWidget *parent = nullptr); - /// A rail needs max > min to span; without it the row falls back to stock. + /** @brief Accept any attribute that declares a range, bounded or not. + * + * Both constraint keys must be present, but their values are not screened. + * Seed is the case that matters: it declares [0, INT_MAX], which no rail can + * span, and it appears on nearly every node. It is rendered in the unbounded + * mode below rather than handed to another design. + */ static bool can_render(const Attribute &attr); int get() const override { return value_; } @@ -63,6 +69,21 @@ class IntSlider : public Control void refresh_field(); void restyle_field(bool editing = false); + /** @brief Advance an unbounded drag to cursor position `x`. + * + * Moves the value by whole steps over the distance dragged rather than to + * the position under the cursor. Ctrl is fine, Shift is coarse, as in stock. + */ + void drag_by(int x, Qt::KeyboardModifiers modifiers); + + /** @brief Move to `value` as one complete edit. + * + * Glides when the rail can show the motion and seats immediately when it + * cannot, so the three callers that just want "go there and commit" -- typed + * value, wheel notch, double-click reset -- do not each repeat the branch. + */ + void commit_value(int value); + int min_ = 0; int max_ = 1; int value_ = 0; @@ -70,10 +91,20 @@ class IntSlider : public Control std::string category_; std::string key_; + /** @brief No usable range, so the thumb reports drag rate, not position. + * + * Fixed at construction: an attribute's constraints do not change under it. + */ + bool unbounded_ = false; + Glide *glide_ = nullptr; ///< animates the painted position only qreal norm_ = 0.0; QLineEdit *field_ = nullptr; bool dragging_ = false; + + // --- unbounded drag reference, both only meaningful while dragging_ + int drag_origin_x_ = 0; + int value_at_press_ = 0; }; } // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp index c1f221f..a84ec16 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/param_slider.hpp @@ -33,11 +33,14 @@ class ParamSlider : public Control const RowContext &ctx, QWidget *parent = nullptr); - /** @brief Decline attributes with no usable range. + /** @brief Accept any attribute that declares a range, bounded or not. * - * A rail needs `max > min` to span. Without both keys meta::common::min/max - * return the numeric limits, which produces a rail no drag can meaningfully - * address -- so the row falls back to the stock spin box instead. + * Both constraint keys must be present: without them the attribute is not + * asking to be a slider at all, and meta::common::min/max would invent + * limits it never declared. + * + * The values themselves are not screened. A range with no usable span is + * rendered in the unbounded mode below rather than declined. */ static bool can_render(const Attribute &attr); @@ -70,6 +73,25 @@ class ParamSlider : public Control void refresh_field(); void restyle_field(bool editing = false); + /** @brief Advance an unbounded drag to cursor position `x`. + * + * Moves the value by the distance dragged rather than to the position under + * the cursor, and moves the thumb to match until it reaches the end of the + * rail. Ctrl is fine, Shift is coarse, as in stock. + */ + void drag_by(int x, Qt::KeyboardModifiers modifiers); + + /// Seat `value` and publish it. No animation: use for unbounded rows. + void apply_value(float value); + + /** @brief Move to `value` as one complete edit. + * + * Glides when the rail can show the motion and seats immediately when it + * cannot, so the three callers that just want "go there and commit" -- typed + * value, wheel notch, double-click reset -- do not each repeat the branch. + */ + void commit_value(float value); + float min_ = 0.f; float max_ = 1.f; float value_ = 0.f; @@ -79,11 +101,21 @@ class ParamSlider : public Control std::string category_; std::string key_; ///< attribute name, for the defaults lookup on reset + /** @brief No usable range, so the thumb reports drag rate, not position. + * + * Fixed at construction: an attribute's constraints do not change under it. + */ + bool unbounded_ = false; + Glide *glide_ = nullptr; ///< animates the displayed position, 0..1 qreal norm_ = 0.0; ///< what is painted; may lag value_ mid-glide QLineEdit *field_ = nullptr; bool dragging_ = false; bool hovered_rail_ = false; + + // --- unbounded drag reference, both only meaningful while dragging_ + int drag_origin_x_ = 0; + float value_at_press_ = 0.f; }; } // namespace meta::qt::industrial diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp index 0970fba..c03df9d 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/slider_chrome.hpp @@ -41,6 +41,19 @@ struct SliderVisual std::string category; ///< selects the group accent for the rail fill bool modified = false; bool locked = false; + + /** @brief The range has no usable limits, so the thumb is a rate handle. + * + * Changes what the rail means. A bounded rail fills from its left edge to + * the thumb, because the thumb's position *is* the value. An unbounded one + * has no such position to fill towards: the thumb rests at the centre and + * reports drag distance, so a fill would be claiming a proportion that does + * not exist. + */ + bool unbounded = false; + + /// Drag in progress. Only read when `unbounded`, to mark the rest position. + bool dragging = false; }; /** @brief Paint label, rail well, accent fill and thumb. @@ -63,18 +76,22 @@ void paint_slider_row(QPainter &painter, * A bound of FLT_MAX or INT_MAX does not mean "a very wide slider", it means * "no limit". A rail a couple of hundred pixels wide cannot show that: every * value a user would type lands in the first pixel, and a drag moves the value - * by astronomical steps. That is the broken behaviour on the unbounded rows. + * by astronomical steps. * - * Declining them here lets them fall through to stock, whose SliderFloat has a - * proper unbounded mode: the handle sits centred at rest and drags relatively - * instead of mapping to an absolute position. Using the fallback chain is the - * point of the design registry, so this belongs in can_render() rather than as - * a special case inside the paint code. + * This used to gate can_render(), so those rows fell through to stock. That + * fixed the drag but cost more than it bought: it hit 197 rows, 86 of them the + * Seed that sits near the top of nearly every node, so almost every panel grew + * a stock row among the industrial ones. The sliders now carry their own + * unbounded mode instead, and this selects between the two. * * The sentinel test deliberately matches stock's `is_range_bounded()` exactly, * against the type's own limits. A looser threshold would leave a gap where a - * merely huge range is declined here but still counted as bounded there, which - * breaks it in both designs rather than neither. + * merely huge range counted as unbounded here but bounded there, so the two + * designs would disagree about which control a row gets. + * + * Note a half-open range still lands here: Seed is [0, INT_MAX], and a lower + * bound alone cannot give the rail a span. The real bound is still enforced, + * it just clamps the value rather than positioning the thumb. */ template bool has_usable_range(T lo, T hi) { diff --git a/MetaUI/qt/src/designs/industrial/int_slider.cpp b/MetaUI/qt/src/designs/industrial/int_slider.cpp index 6154bb4..7289128 100644 --- a/MetaUI/qt/src/designs/industrial/int_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/int_slider.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -15,6 +16,22 @@ namespace meta::qt::industrial { +namespace +{ +/// Thumb position an unbounded row rests at: the middle of the rail. +constexpr qreal kRestNorm = 0.5; + +/** @brief Drag sensitivity for an unbounded row, in pixels per whole step. + * + * Taken from stock SliderInt's PPU_UNBOUNDED rather than picked again, so a + * Seed feels the same now that it no longer falls through to stock. + */ +constexpr qreal kPixelsPerStep = 4.0; + +/// Ctrl divides the step by this, Shift multiplies it. Stock's PPU_MULT_FINE. +constexpr qreal kFineMultiplier = 10.0; +} // namespace + IntSlider::IntSlider(Attribute &attr, const RowContext &ctx, QWidget *parent) @@ -26,8 +43,20 @@ IntSlider::IntSlider(Attribute &attr, min_ = meta::common::min(attr); max_ = meta::common::max(attr); + unbounded_ = !has_usable_range(min_, max_); + + // An inverted or empty range cannot clamp, and std::clamp with hi below lo + // is undefined. Widening to the type's own limits keeps every clamp below + // well formed; an attribute whose bounds contradict each other is already + // the unbounded case as far as the rail is concerned. + if (unbounded_ && !(max_ > min_)) + { + min_ = std::numeric_limits::lowest(); + max_ = std::numeric_limits::max(); + } + value_ = std::clamp(attr.value(), min_, max_); - norm_ = to_norm(value_); + norm_ = unbounded_ ? kRestNorm : to_norm(value_); setFixedHeight(theme().metrics.row_height); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); @@ -53,6 +82,12 @@ IntSlider::IntSlider(Attribute &attr, { norm_ = t; update(); + + // Unbounded: a thumb settling back to centre is not an edit. The + // drag published as it went and end_edit() fired on release, so + // ending one here would close an edit the user has since started. + if (unbounded_) return; + end_edit(); }); glide_->jump(norm_); @@ -78,8 +113,7 @@ IntSlider::IntSlider(Attribute &attr, return; } - begin_edit(); - apply_value(std::clamp(typed, min_, max_), true); + commit_value(typed); }); connect(field_, @@ -95,16 +129,22 @@ bool IntSlider::can_render(const Attribute &attr) !metadata.find(meta::keys::constraints::max)) return false; - // Declines unbounded ranges so they fall through to the stock input, which is - // the right control for a number with no limits. - return has_usable_range(meta::common::min(attr), meta::common::max(attr)); + // The bounds themselves are not screened: a range with no usable span is + // rendered as a rate handle rather than handed to another design. + return true; } void IntSlider::set(const int &value) { value_ = std::clamp(value, min_, max_); - glide_->jump(to_norm(value_)); // a model sync seats immediately - norm_ = to_norm(value_); + + // 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 + // running, which would otherwise fight the position set here. + const qreal target = unbounded_ ? kRestNorm : to_norm(value_); + + glide_->jump(target); // a model sync seats immediately + norm_ = target; refresh_field(); update(); } @@ -141,6 +181,8 @@ void IntSlider::paintEvent(QPaintEvent *) visual.category = category_; visual.modified = is_modified(); visual.locked = is_locked(); + visual.unbounded = unbounded_; + visual.dragging = dragging_; QFont label_font = row_label_font(); visual.label = elide_label(QString::fromStdString(label_), @@ -181,6 +223,26 @@ void IntSlider::mousePressEvent(QMouseEvent *event) } setFocus(Qt::MouseFocusReason); + + if (unbounded_) + { + // Seat the thumb before the drag is measured. A recentre from the previous + // drag may still be running, and its finished() would otherwise arrive + // mid-drag; jump() cancels it without emitting one. + glide_->jump(kRestNorm); + norm_ = kRestNorm; + drag_origin_x_ = event->pos().x(); + value_at_press_ = value_; + + dragging_ = true; + begin_edit(); + update(); + + // Deliberately no set_from_position(): a rate drag measures from where the + // press landed, so pressing the rail must not move the value at all. + return; + } + dragging_ = true; begin_edit(); set_from_position(event->pos().x()); @@ -189,6 +251,13 @@ void IntSlider::mousePressEvent(QMouseEvent *event) void IntSlider::mouseMoveEvent(QMouseEvent *event) { if (!dragging_) return; + + if (unbounded_) + { + drag_by(event->pos().x(), event->modifiers()); + return; + } + set_from_position(event->pos().x()); } @@ -197,6 +266,19 @@ void IntSlider::mouseReleaseEvent(QMouseEvent *event) if (!dragging_) return; dragging_ = false; + + if (unbounded_) + { + drag_by(event->pos().x(), event->modifiers()); + + // The thumb eases back to rest rather than snapping, like everything else + // in this design. The edit is over as soon as the button is up, though: + // holding it open for the animation would stall the model sync behind it. + end_edit(); + glide_->to(kRestNorm); + return; + } + set_from_position(event->pos().x()); end_edit(); } @@ -214,8 +296,7 @@ void IntSlider::mouseDoubleClickEvent(QMouseEvent *event) try { dragging_ = false; - begin_edit(); - apply_value(std::clamp(std::any_cast(def), min_, max_), true); + commit_value(std::any_cast(def)); // the reset glides where it can } catch (const std::bad_any_cast &) { @@ -235,9 +316,11 @@ void IntSlider::handle_wheel(QWheelEvent *event) } // One notch is one unit, which is what an integer control should do - // regardless of how wide its range happens to be. - begin_edit(); - apply_value(std::clamp(value_ + steps, min_, max_), true); + // regardless of how wide its range happens to be. Widened to 64 bits before + // the clamp because a Seed sits in [0, INT_MAX] and value_ + steps would + // otherwise overflow at the top of it. + const long long stepped = static_cast(value_) + steps; + commit_value(int(std::clamp(stepped, min_, max_))); event->accept(); } @@ -278,19 +361,67 @@ void IntSlider::set_from_position(int x) apply_value(from_norm(t), false); } +void IntSlider::drag_by(int x, Qt::KeyboardModifiers modifiers) +{ + const int dx = x - drag_origin_x_; + + qreal ppu = kPixelsPerStep; + if (modifiers & Qt::ControlModifier) + ppu *= kFineMultiplier; + else if (modifiers & Qt::ShiftModifier) + ppu /= kFineMultiplier; + + // The thumb follows the cursor pixel for pixel but stops at the ends of the + // rail. Its travel is an affordance, not a measurement: the value carries on + // changing after the thumb has run out of room, which is the whole point of + // a rate control. + const SliderGeometry g = SliderGeometry::compute(theme(), + width(), + height(), + norm_); + const int travel = std::max(1, g.rail.width() - theme().metrics.thumb_width); + + norm_ = std::clamp(kRestNorm + qreal(dx) / qreal(travel), 0.0, 1.0); + glide_->jump(norm_); // no easing under the cursor, and cancels any recentre + + // Whole steps only, measured from the value at the press rather than + // accumulated per event: an integer row must never show a fraction, and a + // drag out and back has to land exactly where it started. Widened to 64 bits + // because a Seed sits in [0, INT_MAX] and this would overflow near the top. + const long long moved = static_cast(qreal(dx) / ppu); + const long long target = static_cast(value_at_press_) + moved; + + apply_value(int(std::clamp(target, min_, max_)), false); +} + +void IntSlider::commit_value(int value) +{ + begin_edit(); + apply_value(std::clamp(value, min_, 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. + if (unbounded_) end_edit(); +} + void IntSlider::apply_value(int value, bool glide) { const bool changed = value != value_; value_ = value; - if (glide) - { - glide_->to(to_norm(value_)); - } - else + // Unbounded: the thumb is a rate affordance rather than a position, so it is + // never driven from the value. drag_by() owns it and release eases it home. + if (!unbounded_) { - glide_->jump(to_norm(value_)); // a drag tracks the cursor, no glide - norm_ = to_norm(value_); + if (glide) + { + glide_->to(to_norm(value_)); + } + else + { + glide_->jump(to_norm(value_)); // a drag tracks the cursor, no glide + norm_ = to_norm(value_); + } } refresh_field(); diff --git a/MetaUI/qt/src/designs/industrial/param_slider.cpp b/MetaUI/qt/src/designs/industrial/param_slider.cpp index a0b7898..358f6dd 100644 --- a/MetaUI/qt/src/designs/industrial/param_slider.cpp +++ b/MetaUI/qt/src/designs/industrial/param_slider.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -19,7 +20,20 @@ namespace meta::qt::industrial namespace { constexpr qreal kLogFloor = 1e-6; ///< below this a log mapping is undefined -} + +/// Thumb position an unbounded row rests at: the middle of the rail. +constexpr qreal kRestNorm = 0.5; + +/** @brief Drag sensitivity for an unbounded row, in pixels per unit. + * + * Taken from stock SliderFloat's PPU_F rather than picked again, so a row that + * used to fall through to stock feels the same now that it does not. + */ +constexpr qreal kPixelsPerUnit = 200.0; + +/// Ctrl divides the step by this, Shift multiplies it. Stock's PPU_MULT_FINE. +constexpr qreal kFineMultiplier = 10.0; +} // namespace ParamSlider::ParamSlider(Attribute &attr, const RowContext &ctx, @@ -36,12 +50,25 @@ ParamSlider::ParamSlider(Attribute &attr, false); decimals_ = meta::common::try_get_format_decimals(meta::common::format(attr)); + unbounded_ = !has_usable_range(min_, max_); + + // An inverted or empty range cannot clamp, and std::clamp with hi below lo + // is undefined. Widening to the type's own limits keeps every clamp below + // well formed; an attribute whose bounds contradict each other is already + // the unbounded case as far as the rail is concerned. + if (unbounded_ && !(max_ > min_)) + { + min_ = std::numeric_limits::lowest(); + max_ = std::numeric_limits::max(); + } + // A log mapping needs a strictly positive lower bound; fall back to linear - // rather than producing NaNs across the whole rail. - if (log_scale_ && min_ <= kLogFloor) log_scale_ = false; + // rather than producing NaNs across the whole rail. An unbounded range has + // 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_); - norm_ = to_norm(value_); + norm_ = unbounded_ ? kRestNorm : to_norm(value_); setFixedHeight(theme().metrics.row_height); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); @@ -53,8 +80,16 @@ ParamSlider::ParamSlider(Attribute &attr, [this](qreal t) { norm_ = t; - value_ = from_norm(t); - refresh_field(); + + // Unbounded: the glide carries the thumb home after a drag and + // does nothing else. Deriving the value from the thumb here would + // drag it back towards the centre along with the thumb. + if (!unbounded_) + { + value_ = from_norm(t); + refresh_field(); + } + update(); }); @@ -66,9 +101,16 @@ ParamSlider::ParamSlider(Attribute &attr, [this](qreal t) { norm_ = t; + update(); + + // Unbounded: a thumb settling back to centre is not an edit. The + // drag published as it went and end_edit() fired on release, so a + // commit here would raise a second edit out of an animation the + // user has already let go of. + if (unbounded_) return; + value_ = from_norm(t); refresh_field(); - update(); notify_value_changed(); end_edit(); }); @@ -95,8 +137,7 @@ ParamSlider::ParamSlider(Attribute &attr, return; } - begin_edit(); - glide_->to(to_norm(std::clamp(typed, min_, max_))); + commit_value(typed); }); connect(field_, @@ -114,18 +155,22 @@ bool ParamSlider::can_render(const Attribute &attr) !metadata.find(meta::keys::constraints::max)) return false; - // Declines unbounded ranges so they fall through to the stock input, which is - // the right control for a number with no limits. - return has_usable_range(meta::common::min(attr), meta::common::max(attr)); + // The bounds themselves are not screened: a range with no usable span is + // rendered as a rate handle rather than handed to another design. + return true; } void ParamSlider::set(const float &value) { const float clamped = std::clamp(value, min_, 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 + // running, which would otherwise fight the position set here. + glide_->jump(unbounded_ ? kRestNorm : to_norm(clamped)); + // jump() emits tick(), which derives value_ back out of the normalised // position -- lossy under a log mapping. Seat the authoritative value after. - glide_->jump(to_norm(clamped)); // a model sync seats immediately, no glide value_ = clamped; refresh_field(); @@ -184,6 +229,8 @@ void ParamSlider::paintEvent(QPaintEvent *) visual.category = category_; visual.modified = is_modified(); visual.locked = is_locked(); + visual.unbounded = unbounded_; + visual.dragging = dragging_; QFont label_font = row_label_font(); visual.label = elide_label(QString::fromStdString(label_), @@ -228,6 +275,26 @@ void ParamSlider::mousePressEvent(QMouseEvent *event) } setFocus(Qt::MouseFocusReason); + + if (unbounded_) + { + // Seat the thumb before the drag is measured. A recentre from the previous + // drag may still be running, and its finished() would otherwise arrive + // mid-drag; jump() cancels it without emitting one. + glide_->jump(kRestNorm); + norm_ = kRestNorm; + drag_origin_x_ = event->pos().x(); + value_at_press_ = value_; + + dragging_ = true; + begin_edit(); + update(); + + // Deliberately no set_from_position(): a rate drag measures from where the + // press landed, so pressing the rail must not move the value at all. + return; + } + dragging_ = true; begin_edit(); set_from_position(event->pos().x()); @@ -236,6 +303,13 @@ void ParamSlider::mousePressEvent(QMouseEvent *event) void ParamSlider::mouseMoveEvent(QMouseEvent *event) { if (!dragging_) return; + + if (unbounded_) + { + drag_by(event->pos().x(), event->modifiers()); + return; + } + set_from_position(event->pos().x()); } @@ -244,6 +318,19 @@ void ParamSlider::mouseReleaseEvent(QMouseEvent *event) if (!dragging_) return; dragging_ = false; + + if (unbounded_) + { + drag_by(event->pos().x(), event->modifiers()); + + // The thumb eases back to rest rather than snapping, like everything else + // in this design. The edit is over as soon as the button is up, though: + // holding it open for the animation would stall the model sync behind it. + end_edit(); + glide_->to(kRestNorm); + return; + } + set_from_position(event->pos().x()); end_edit(); } @@ -260,10 +347,8 @@ void ParamSlider::mouseDoubleClickEvent(QMouseEvent *event) try { - const float target = std::clamp(std::any_cast(def), min_, max_); dragging_ = false; - begin_edit(); - glide_->to(to_norm(target)); // the reset glides like everything else + commit_value(std::any_cast(def)); // the reset glides where it can } catch (const std::bad_any_cast &) { @@ -282,6 +367,15 @@ void ParamSlider::handle_wheel(QWheelEvent *event) return; } + if (unbounded_) + { + // One unit per notch. A percentage of the rail is the wrong measure when + // the rail represents no span, and it is what stock does here too. + commit_value(value_ + float(steps)); + event->accept(); + return; + } + // One notch moves 1% of the rail, which stays sane under a log mapping. begin_edit(); glide_->to(std::clamp(norm_ + steps * 0.01, 0.0, 1.0)); @@ -335,6 +429,63 @@ void ParamSlider::apply_norm(qreal t) notify_value_changed(); } +void ParamSlider::drag_by(int x, Qt::KeyboardModifiers modifiers) +{ + const int dx = x - drag_origin_x_; + + qreal ppu = kPixelsPerUnit; + if (modifiers & Qt::ControlModifier) + ppu *= kFineMultiplier; + else if (modifiers & Qt::ShiftModifier) + ppu /= kFineMultiplier; + + // The thumb follows the cursor pixel for pixel but stops at the ends of the + // rail. Its travel is an affordance, not a measurement: the value carries on + // changing after the thumb has run out of room, which is the whole point of + // a rate control. + const SliderGeometry g = SliderGeometry::compute(theme(), + width(), + height(), + norm_); + const int travel = std::max(1, g.rail.width() - theme().metrics.thumb_width); + + norm_ = std::clamp(kRestNorm + qreal(dx) / qreal(travel), 0.0, 1.0); + glide_->jump(norm_); // no easing under the cursor, and cancels any recentre + + // Measured from the value at the press rather than accumulated per event, so + // a drag out and back returns to exactly where it started. + apply_value(float(qreal(value_at_press_) + qreal(dx) / ppu)); +} + +void ParamSlider::apply_value(float value) +{ + const float clamped = std::clamp(value, min_, max_); + const bool changed = clamped != value_; + + value_ = clamped; + refresh_field(); // runs even unchanged, to normalise what was typed + update(); + + if (changed) notify_value_changed(); +} + +void ParamSlider::commit_value(float value) +{ + begin_edit(); + + const float clamped = std::clamp(value, min_, max_); + + if (!unbounded_) + { + glide_->to(to_norm(clamped)); // finished() commits and ends the edit + return; + } + + // Nothing to glide towards: the thumb is already at rest and stays there. + apply_value(clamped); + end_edit(); +} + QString ParamSlider::format_value(float value) const { return QString::number(value, 'f', decimals_); diff --git a/MetaUI/qt/src/designs/industrial/slider_chrome.cpp b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp index 9bdccdb..c135899 100644 --- a/MetaUI/qt/src/designs/industrial/slider_chrome.cpp +++ b/MetaUI/qt/src/designs/industrial/slider_chrome.cpp @@ -89,7 +89,10 @@ void paint_slider_row(QPainter &painter, m.rail_radius); // --- fill. Always the group accent; never a state colour. - if (geometry.fill.width() > 0) + // + // Skipped when unbounded: with no limits there is no proportion of the rail + // to fill, and a bar growing from the left would read as one. + if (!visual.unbounded && geometry.fill.width() > 0) { painter.setPen(Qt::NoPen); painter.setBrush(theme.rail_fill(visual.category, visual.locked)); @@ -99,6 +102,19 @@ void paint_slider_row(QPainter &painter, m.rail_radius); } + // --- rest marker. Only while an unbounded drag is under way, and only then: + // at rest the thumb covers this exactly, so drawing it always would look + // like a stray hairline under the thumb. + if (visual.unbounded && visual.dragging) + { + painter.setPen(Qt::NoPen); + painter.setBrush(theme.thumb_grip); + painter.drawRect(QRect(geometry.rail.center().x(), + geometry.thumb.top(), + 1, + geometry.thumb.height())); + } + // --- thumb painter.setOpacity(visual.locked ? theme.locked_thumb_alpha : 1.0); From 9abdd0c627b9881d725ddf8d8c72dfc7a99a990a Mon Sep 17 00:00:00 2001 From: Leonhardmaster2 Date: Sun, 6 Sep 2026 00:09:13 +0200 Subject: [PATCH 2/3] fix(qt): soften the industrial panel and make its animations retargetable Rounding and text first. Corners were too hard throughout, so the shared radius goes 2 to 4, the rail 1 to 3 and the section card 6 to 10, and the scrollbar handle reads its radius from the metrics instead of hardcoding one. The section header now rounds its own corners to the card radius, all four when collapsed and the top pair when expanded, because it is opaque and sits on top of the card, so any corner it did not round it squared off again. That is why the top of a section looked nothing like the bottom. Row labels go from 12px to 13px and from normal to medium weight, and every ink step sits closer to the text colour, on both the reference colourway and the palette derived one. At the old contrast a label leaned on antialiasing to form its stems and the panel read as small grey print next to the rest of the app. Then the animations. Both the section reveal and the combo popup restarted from an endpoint instead of from where they currently were, so toggling one while it was still moving jumped it to full height or to zero before easing off again. Both retarget from the current value now. The section header also gets a real fixed height rather than a stylesheet min-height, which does not raise the minimum the layout honours, so the header was one of the things that got squeezed while a section animated and its title visibly crept upward. Two tests cover it: section geometry is asserted every paint through a run of fast toggles at three viewport widths, and the popup across 16 open and dismiss scenarios. --- .../meta_qt/designs/industrial/combo.hpp | 7 +- .../meta_qt/designs/industrial/section.hpp | 3 + MetaUI/qt/include/meta_qt/ui/theme.hpp | 23 ++- MetaUI/qt/src/designs/industrial/combo.cpp | 98 +++++++--- .../src/designs/industrial/panel_chrome.cpp | 5 +- MetaUI/qt/src/designs/industrial/section.cpp | 96 +++++++++- MetaUI/qt/src/ui/theme.cpp | 28 ++- .../test_combo_animation/CMakeLists.txt | 2 + tests/test_qt/test_combo_animation/main.cpp | 145 +++++++++++++++ .../test_section_animation/CMakeLists.txt | 2 + tests/test_qt/test_section_animation/main.cpp | 173 ++++++++++++++++++ 11 files changed, 528 insertions(+), 54 deletions(-) create mode 100644 tests/test_qt/test_combo_animation/CMakeLists.txt create mode 100644 tests/test_qt/test_combo_animation/main.cpp create mode 100644 tests/test_qt/test_section_animation/CMakeLists.txt create mode 100644 tests/test_qt/test_section_animation/main.cpp diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp index b1291fa..7f0e559 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp @@ -63,22 +63,25 @@ class ComboPopup : public QWidget void mouseReleaseEvent(QMouseEvent *event) override; void keyPressEvent(QKeyEvent *event) override; void hideEvent(QHideEvent *event) override; + void closeEvent(QCloseEvent *event) override; private: int index_at(const QPoint &pos) const; int row_height() const; + void animate_to(int height); - /// Portion of the fixed-size window currently revealed by the open animation. + /// Portion of the fixed-size window currently revealed by the animation. QRect card_rect() const; const Theme *theme_ = nullptr; QStringList items_; int current_ = -1; int hovered_ = -1; - QVariantAnimation *open_animation_ = nullptr; + QVariantAnimation *animation_ = nullptr; int full_height_ = 0; int revealed_ = 0; bool flipped_ = false; + bool closing_ = false; }; /// Shared closed-state painting for both combo flavours. diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp index 0590e12..568229a 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/section.hpp @@ -41,6 +41,7 @@ class ClipBox : public QWidget int body_height() const; QSize sizeHint() const override; + QSize minimumSizeHint() const override; protected: void resizeEvent(QResizeEvent *event) override; @@ -49,6 +50,8 @@ class ClipBox : public QWidget bool eventFilter(QObject *watched, QEvent *event) override; private: + void update_reveal_geometry(); + QWidget *body_ = nullptr; int reveal_ = 0; bool follow_ = true; diff --git a/MetaUI/qt/include/meta_qt/ui/theme.hpp b/MetaUI/qt/include/meta_qt/ui/theme.hpp index b07f074..181b362 100644 --- a/MetaUI/qt/include/meta_qt/ui/theme.hpp +++ b/MetaUI/qt/include/meta_qt/ui/theme.hpp @@ -63,7 +63,7 @@ struct Metrics // --- rail and thumb int rail_height = 6; - int rail_radius = 1; + int rail_radius = 3; int thumb_width = 10; int thumb_height = 18; @@ -82,11 +82,11 @@ struct Metrics int section_row_spacing = 10; int section_card_margin = 14; ///< inset of a card from the panel edge int section_card_gap = 10; ///< vertical gap between consecutive cards - int section_card_radius = 6; + int section_card_radius = 10; int row_bar_height = 30; ///< the bar a value row is drawn inside // --- shared - int radius = 2; + int radius = 4; int glide_ms = 260; ///< value glide; nothing snaps int switch_ms = 150; ///< switch knob slide int section_ms = 200; ///< disclosure rotation @@ -155,13 +155,18 @@ struct Theme QColor field_border_hover{"#5a5a5a"}; // --- ink. Only text encodes state; see state_ink(). - QColor ink_primary{"#e0e0e0"}; - QColor ink_section_title{"#d0d0d0"}; - QColor ink_secondary{"#9a9a9a"}; ///< value at default - QColor ink_dim{"#8a8a8a"}; - QColor ink_locked{"#606060"}; + // + // Every step here sits closer to white than the first pass did. The design + // was sampled at a contrast that looked right in isolation but reads as + // washed out next to the host's own panels, and the row label is the text a + // user spends the most time on. + QColor ink_primary{"#ececec"}; + QColor ink_section_title{"#e0e0e0"}; + QColor ink_secondary{"#b9b9b9"}; ///< value at default + QColor ink_dim{"#a4a4a4"}; + QColor ink_locked{"#6c6c6c"}; QColor ink_modified{"#ffffff"}; - QColor ink_icon{"#c9c9c9"}; + QColor ink_icon{"#d6d6d6"}; // --- metal QColor thumb_top{"#d6d6d6"}; diff --git a/MetaUI/qt/src/designs/industrial/combo.cpp b/MetaUI/qt/src/designs/industrial/combo.cpp index 9621e20..3a63d5a 100644 --- a/MetaUI/qt/src/designs/industrial/combo.cpp +++ b/MetaUI/qt/src/designs/industrial/combo.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -36,23 +37,34 @@ ComboPopup::ComboPopup(const Theme &theme, const QStringList &items, int current, QWidget *parent) - : QWidget(parent, Qt::Popup), + : QWidget(parent, Qt::Popup | Qt::FramelessWindowHint | + Qt::NoDropShadowWindowHint), theme_(&theme), items_(items), current_(current), hovered_(current) { - // Must be set before the native window is created, which happens on the first - // show(). Setting it later leaves the unrevealed part of the surface painting - // opaque black instead of nothing. - // - // WA_NoSystemBackground is deliberately *not* set alongside it: together they - // leave the surface undefined here rather than clear. + // Windows needs BOTH the frameless flag and an alpha backing store before + // show(); otherwise the unrevealed part of the popup is an opaque black box. + // Disable the native popup shadow too: it outlines the full window even + // while the card is only partially revealed. paintEvent owns the border. setAttribute(Qt::WA_TranslucentBackground); setAttribute(Qt::WA_DeleteOnClose); setMouseTracking(true); setFocusPolicy(Qt::StrongFocus); + + animation_ = new QVariantAnimation(this); + animation_->setDuration(theme_->metrics.section_ms); + animation_->setEasingCurve(QEasingCurve::OutCubic); + connect(animation_, &QVariantAnimation::valueChanged, this, + [this](const QVariant &value) + { + revealed_ = value.toInt(); + update(); + }); + connect(animation_, &QVariantAnimation::finished, this, + [this]() { if (closing_) close(); }); } int ComboPopup::row_height() const { return kRowHeight; } @@ -85,30 +97,30 @@ void ComboPopup::popup_for(const QRect &field_global) // to full size, and resizing a native window every frame is expensive anyway. // Reveal the card inside a fixed, translucent window instead. setGeometry(left, y, width, full_height_); + revealed_ = 0; + closing_ = false; + animate_to(full_height_); show(); setFocus(Qt::PopupFocusReason); +} - open_animation_ = new QVariantAnimation(this); - open_animation_->setDuration(theme_->metrics.section_ms); - open_animation_->setEasingCurve(QEasingCurve::OutCubic); - open_animation_->setStartValue(0); - open_animation_->setEndValue(full_height_); - - connect(open_animation_, - &QVariantAnimation::valueChanged, - this, - [this](const QVariant &v) - { - revealed_ = v.toInt(); - update(); - }); - - open_animation_->start(); +void ComboPopup::animate_to(int height) +{ + animation_->stop(); + { + // Retarget from the current reveal without emitting samples at the old + // animation time, including when dismissed before opening has finished. + QSignalBlocker blocker(animation_); + animation_->setStartValue(revealed_); + animation_->setEndValue(height); + animation_->setCurrentTime(0); + } + animation_->start(); } QRect ComboPopup::card_rect() const { - const int h = revealed_ > 0 ? revealed_ : full_height_; + const int h = std::clamp(revealed_, 0, full_height_); // Opening downward, the card grows from its top edge, which sits against the // field. Flipped, it grows upward from its bottom edge, which is the edge @@ -119,7 +131,8 @@ QRect ComboPopup::card_rect() const int ComboPopup::index_at(const QPoint &pos) const { - if (!rect().contains(pos)) return -1; + if (!card_rect().contains(pos) || pos.y() < kPopupPadding || + pos.y() >= full_height_ - kPopupPadding) return -1; const int index = (pos.y() - kPopupPadding) / row_height(); return index >= 0 && index < items_.size() ? index : -1; @@ -128,10 +141,15 @@ int ComboPopup::index_at(const QPoint &pos) const void ComboPopup::paintEvent(QPaintEvent *) { QPainter painter(this); + // Clear the entire backing store, including the area uncovered by closing. + painter.setCompositionMode(QPainter::CompositionMode_Source); + painter.fillRect(rect(), Qt::transparent); + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); painter.setRenderHint(QPainter::Antialiasing, true); const Theme &t = *theme_; const QRect card = card_rect(); + if (card.isEmpty()) return; // Everything is clipped to the revealed card, so the rows stay put and are // uncovered rather than sliding. Laying them out against the animating height @@ -172,6 +190,7 @@ void ComboPopup::paintEvent(QPaintEvent *) void ComboPopup::mouseMoveEvent(QMouseEvent *event) { + if (closing_) return; const int index = index_at(event->pos()); if (index != hovered_) { @@ -182,9 +201,10 @@ void ComboPopup::mouseMoveEvent(QMouseEvent *event) void ComboPopup::mousePressEvent(QMouseEvent *event) { + if (closing_) return; // Overriding this at all suppresses Qt's built-in "press outside dismisses", // so an outside press has to be handled here. - if (!rect().contains(event->pos())) + if (!card_rect().contains(event->pos())) { close(); return; @@ -195,19 +215,21 @@ void ComboPopup::mousePressEvent(QMouseEvent *event) void ComboPopup::mouseReleaseEvent(QMouseEvent *event) { + if (closing_) return; const int index = index_at(event->pos()); if (index >= 0) { - Q_EMIT selected(index); close(); + Q_EMIT selected(index); return; } - if (!rect().contains(event->pos())) close(); + if (!card_rect().contains(event->pos())) close(); } void ComboPopup::keyPressEvent(QKeyEvent *event) { + if (closing_) return; switch (event->key()) { case Qt::Key_Down: @@ -220,8 +242,8 @@ void ComboPopup::keyPressEvent(QKeyEvent *event) return; case Qt::Key_Return: case Qt::Key_Enter: - if (hovered_ >= 0) Q_EMIT selected(hovered_); close(); + if (hovered_ >= 0) Q_EMIT selected(hovered_); return; case Qt::Key_Escape: close(); return; default: break; @@ -230,8 +252,26 @@ void ComboPopup::keyPressEvent(QKeyEvent *event) QWidget::keyPressEvent(event); } +void ComboPopup::closeEvent(QCloseEvent *event) +{ + if (!isVisible() || revealed_ == 0) + { + animation_->stop(); + QWidget::closeEvent(event); + return; + } + + // Keep the popup alive until its card reaches zero height. Further dismissal + // events must not restart the animation or select an item a second time. + event->ignore(); + if (closing_) return; + closing_ = true; + animate_to(0); +} + void ComboPopup::hideEvent(QHideEvent *event) { + animation_->stop(); // hideEvent rather than destroyed(): WA_DeleteOnClose defers deletion by an // event-loop pass, by which point the dismissing click has already been // processed and reopened the popup. diff --git a/MetaUI/qt/src/designs/industrial/panel_chrome.cpp b/MetaUI/qt/src/designs/industrial/panel_chrome.cpp index a71153f..e29c8fe 100644 --- a/MetaUI/qt/src/designs/industrial/panel_chrome.cpp +++ b/MetaUI/qt/src/designs/industrial/panel_chrome.cpp @@ -17,7 +17,7 @@ QString scrollbar_stylesheet(const Theme &theme) "QScrollBar::handle:vertical {" " background: %1;" " min-height: 30px;" - " border-radius: 2px;" + " border-radius: %3px;" " margin: 2px 3px 2px 3px;" "}" "QScrollBar::handle:vertical:hover { background: %2; }" @@ -28,7 +28,8 @@ QString scrollbar_stylesheet(const Theme &theme) "QScrollBar::add-page:vertical," "QScrollBar::sub-page:vertical { background: transparent; }") .arg(theme.field_border.name()) - .arg(theme.field_border_hover.name()); + .arg(theme.field_border_hover.name()) + .arg(theme.metrics.radius); } QString tooltip_stylesheet(const Theme &theme) diff --git a/MetaUI/qt/src/designs/industrial/section.cpp b/MetaUI/qt/src/designs/industrial/section.cpp index eb657ac..dbf5575 100644 --- a/MetaUI/qt/src/designs/industrial/section.cpp +++ b/MetaUI/qt/src/designs/industrial/section.cpp @@ -5,6 +5,7 @@ #include +#include #include #include #include @@ -64,13 +65,36 @@ QString header_stylesheet(const Theme &theme) " min-height: %3px;" " text-align: left;" " font-weight: bold;" + // The header is opaque and sits directly on top of the card + // the section paints, so any corner it does not round itself + // it squares off again. Leaving these unset is what made the + // top of a section look nothing like the bottom: the body + // rounds the lower pair through its own stylesheet, so only + // the upper pair came out square. + // + // All four here, because a collapsed section is nothing but + // its header and has to round the whole card on its own. + " border-top-left-radius: %6px;" + " border-top-right-radius: %6px;" + " border-bottom-left-radius: %6px;" + " border-bottom-right-radius: %6px;" "}" - "QToolButton:hover { background-color: %4; color: %5; }") + "QToolButton:hover { background-color: %4; color: %5; }" + // Expanded, the body underneath carries the bottom corners, + // so the header has to square off or the two round away from + // each other and leave a notch in the seam. Last in the sheet + // so it wins over the grouped rule above whatever else + // matches. + "QToolButton:checked {" + " border-bottom-left-radius: 0px;" + " border-bottom-right-radius: 0px;" + "}") .arg(theme.section_header.name()) .arg(theme.ink_section_title.name()) .arg(m.section_header_height) .arg(theme.section_header_hover.name()) - .arg(theme.ink_primary.name()); + .arg(theme.ink_primary.name()) + .arg(m.section_card_radius); } } // namespace @@ -104,7 +128,7 @@ bool ClipBox::eventFilter(QObject *watched, QEvent *event) if (watched == body_ && event->type() == QEvent::LayoutRequest) { body_->setGeometry(0, 0, width(), body_height()); - if (follow_) updateGeometry(); + if (follow_) update_reveal_geometry(); } return QWidget::eventFilter(watched, event); @@ -124,13 +148,42 @@ void ClipBox::set_reveal(int px) { follow_ = false; reveal_ = std::max(0, px); - updateGeometry(); + update_reveal_geometry(); } void ClipBox::follow_body() { follow_ = true; + update_reveal_geometry(); +} + +void ClipBox::update_reveal_geometry() +{ updateGeometry(); + if (!isVisible()) return; + + // LayoutRequest travels one parent per event-loop pass. During an animation + // that lets inner layouts squeeze sections into the previous frame's height + // before the scroll content learns its new minimum. Invalidate the whole + // chain first, then allocate from the outside in before anything is painted. + QList ancestors; + for (QWidget *widget = parentWidget(); widget; widget = widget->parentWidget()) + { + if (auto *ancestor = widget->layout()) + { + ancestor->invalidate(); + ancestors.prepend(ancestor); + } + // The scroll content's minimum drives its viewport. Layouts outside that + // scroll area do not need to be recalculated on every animation tick. + QWidget *parent = widget->parentWidget(); + auto *scroll = parent + ? qobject_cast(parent->parentWidget()) + : nullptr; + if (scroll && scroll->viewport() == parent) break; + if (widget->isWindow()) break; + } + for (auto *ancestor : ancestors) ancestor->activate(); } QSize ClipBox::sizeHint() const @@ -139,6 +192,15 @@ QSize ClipBox::sizeHint() const return QSize(w, follow_ ? body_height() : reveal_); } +QSize ClipBox::minimumSizeHint() const +{ + // A Fixed size policy alone does not protect the reveal when an ancestor + // layout is briefly smaller than its new size hint. Propagate the animated + // height as a minimum so the scroll content grows instead of clipping every + // taller section to the same height. Keep horizontal sizing independent. + return QSize(0, sizeHint().height()); +} + void ClipBox::resizeEvent(QResizeEvent *event) { // Keep the body at full height whatever this widget's height is. The crop is @@ -181,6 +243,15 @@ Section::Section(const QString &title, const Theme &theme, QWidget *parent) toggle_button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); toggle_button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + // Pin the header's height instead of leaving it to the stylesheet's + // min-height. A QSS min-height styles the button without raising the + // minimum the *layout* honours, so while a section is animating and the + // panel is handing out less height than the children want, the header is + // one of the things that gives. That is what makes the title text creep + // upward during a fast collapse and only settle once the animation ends. + // A fixed height cannot be taken from. + toggle_button->setFixedHeight(theme.metrics.section_header_height); + // Fixed vertically: the base class leaves sections Preferred, which lets the // panel's QVBoxLayout hand each one a share of the leftover space and // re-divide it whenever any section changes height. The trailing stretch in @@ -279,7 +350,7 @@ void Section::set_expanded(bool new_state) // The first call restores persisted state during construction, before // anything is on screen. Animating that would play every section open at // startup, so seat it directly. - if (first_apply_ || was_expanded == new_state) + if (first_apply_) { first_apply_ = false; animation_->stop(); @@ -294,11 +365,22 @@ void Section::set_expanded(bool new_state) return; } + // Reapplying state must not finish a transition that is already heading there. + if (was_expanded == new_state) return; + const int full = clip_->body_height(); + const int current = clip_->sizeHint().height(); animation_->stop(); // a running animation ignores a retargeted end value - animation_->setStartValue(new_state ? 0 : full); - animation_->setEndValue(new_state ? full : 0); + clip_->set_reveal(current); + { + // Updating endpoints can emit values at the previous animation time. + // Only publish samples after the new transition has been rewound. + QSignalBlocker blocker(animation_); + animation_->setStartValue(current); + animation_->setEndValue(new_state ? full : 0); + animation_->setCurrentTime(0); + } animation_->start(); update(); diff --git a/MetaUI/qt/src/ui/theme.cpp b/MetaUI/qt/src/ui/theme.cpp index c1cff23..68a7b4f 100644 --- a/MetaUI/qt/src/ui/theme.cpp +++ b/MetaUI/qt/src/ui/theme.cpp @@ -76,7 +76,19 @@ QFont row_label_font() // the host is using, so the panel blends into the application instead of // announcing itself. QFont font = QApplication::font(); - font.setPixelSize(12); + + // 13px rather than 12. At 12 the labels sat a step below the host's own + // text and the panel read as small print; 13 matches the value field's mono + // face, so a row's two halves are the same size. + font.setPixelSize(13); + + // Medium rather than the default Normal. At this size a regular weight + // leaves the stems thin enough that antialiasing does most of the work of + // forming them, which reads as blurry rather than light. One step up puts + // enough ink in the glyph to look deliberate, without promoting the row + // label to the weight the section titles use. + font.setWeight(QFont::Medium); + return font; } @@ -149,11 +161,17 @@ Theme Theme::from_palette(const QPalette &palette, const std::string &name) // --- ink. Dimming blends towards the window, so it reads as "less // prominent" whichever side of the light/dark line the scheme sits on. + // + // Every blend is shallower than the first pass. Dimming towards the window + // is the right formula, but it was applied hard enough that a row label and + // its value both landed well short of the host's own text, which reads as + // washed out rather than as a hierarchy. These keep the same ordering with + // less distance between the steps. t.ink_primary = text; - t.ink_section_title = mix(text, window, 0.12); - t.ink_secondary = mix(text, window, 0.38); - t.ink_dim = mix(text, window, 0.48); - t.ink_icon = mix(text, window, 0.20); + t.ink_section_title = mix(text, window, 0.05); + t.ink_secondary = mix(text, window, 0.22); + t.ink_dim = mix(text, window, 0.34); + t.ink_icon = mix(text, window, 0.12); t.ink_locked = palette.color(QPalette::Disabled, QPalette::Text); // BrightText is the maximum-contrast ink, which is exactly what "modified" diff --git a/tests/test_qt/test_combo_animation/CMakeLists.txt b/tests/test_qt/test_combo_animation/CMakeLists.txt new file mode 100644 index 0000000..e037321 --- /dev/null +++ b/tests/test_qt/test_combo_animation/CMakeLists.txt @@ -0,0 +1,2 @@ +add_executable(test_combo_animation main.cpp) +target_link_libraries(test_combo_animation meta_qt) diff --git a/tests/test_qt/test_combo_animation/main.cpp b/tests/test_qt/test_combo_animation/main.cpp new file mode 100644 index 0000000..95961eb --- /dev/null +++ b/tests/test_qt/test_combo_animation/main.cpp @@ -0,0 +1,145 @@ +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#include +#endif + +#include + +#include "meta_qt/designs/industrial/combo.hpp" + +using meta::qt::industrial::ComboPopup; + +namespace +{ +int failures = 0; + +void check(bool condition, const char *message) +{ + if (!condition) + { + ++failures; + std::cerr << message << '\n'; + } +} + +QImage frame(ComboPopup *popup) +{ + return popup->grab().toImage().convertToFormat(QImage::Format_ARGB32); +} + +int opaque_rows(const QImage &image) +{ + int count = 0; + for (int y = 0; y < image.height(); ++y) + if (qAlpha(image.pixel(image.width() / 2, y))) ++count; + return count; +} + +void key(ComboPopup *popup, int code) +{ + QKeyEvent event(QEvent::KeyPress, code, Qt::NoModifier); + QApplication::sendEvent(popup, &event); +} + +void mouse(ComboPopup *popup, QEvent::Type type, const QPoint &pos) +{ + QMouseEvent event(type, QPointF(pos), QPointF(popup->mapToGlobal(pos)), + Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(popup, &event); +} + +void exercise(bool flipped, int dismissal, bool interrupt_open) +{ + meta::qt::Theme theme; + QWidget owner; + QPointer popup = new ComboPopup( + theme, {"add", "exclusion", "gradients", "maximum", "replace"}, 0, + &owner); + popup->setAttribute(Qt::WA_DontShowOnScreen); + int selections = 0; + QObject::connect(popup, &ComboPopup::selected, + [&selections](int) { ++selections; }); + + const QRect screen = QApplication::primaryScreen()->availableGeometry(); + const QRect field(screen.left() + 100, + flipped ? screen.bottom() - 30 : screen.top() + 30, + 240, 24); + popup->popup_for(field); + auto *animation = popup->findChild(); + animation->pause(); + + check(popup->windowFlags().testFlag(Qt::FramelessWindowHint), + "Windows translucent popup must be frameless"); +#ifdef Q_OS_WIN + if (QApplication::platformName() == "windows") + { + // QWidget::grab() cannot see the OS shadow. Inspect the native window + // class as well so a full-size outline cannot escape the pixel checks. + const auto hwnd = reinterpret_cast(popup->winId()); + check((GetClassLongPtr(hwnd, GCL_STYLE) & CS_DROPSHADOW) == 0, + "native popup shadow must not outline the unrevealed window"); + } +#endif + check(opaque_rows(frame(popup)) == 0, + "zero reveal must not flash the fully open card"); + + animation->setCurrentTime(theme.metrics.section_ms / 5); + const QImage opening = frame(popup); + const int partial = opaque_rows(opening); + check(partial > 0 && partial < opening.height(), "opening must reveal gradually"); + const int hidden_y = flipped ? 0 : opening.height() - 1; + check(qAlpha(opening.pixel(opening.width() / 2, hidden_y)) == 0, + "unrevealed popup surface must be transparent"); + + if (!interrupt_open) + animation->setCurrentTime(theme.metrics.section_ms); + const int before_close = opaque_rows(frame(popup)); + switch (dismissal) + { + case 0: key(popup, Qt::Key_Escape); break; + case 1: mouse(popup, QEvent::MouseButtonPress, QPoint(-10, -10)); break; + case 2: key(popup, Qt::Key_Return); break; + case 3: + mouse(popup, QEvent::MouseButtonRelease, + QPoint(popup->width() / 2, flipped ? popup->height() - 16 : 16)); + break; + } + check(popup && popup->isVisible(), "dismissal must keep popup visible while closing"); + if (!popup || !popup->isVisible()) return; + check(opaque_rows(frame(popup)) == before_close, + "closing must start at current reveal without jumping"); + animation->pause(); + animation->setCurrentTime(theme.metrics.section_ms / 3); + const int during_close = opaque_rows(frame(popup)); + check(during_close > 0 && during_close < before_close, + "closing must shrink and clear the previously painted surface"); + key(popup, Qt::Key_Return); + popup->close(); + check(opaque_rows(frame(popup)) == during_close, + "repeated dismissal must not restart closing"); + check(selections == (dismissal >= 2 ? 1 : 0), + "selection must be emitted exactly once, and never on dismissal"); + + animation->setCurrentTime(theme.metrics.section_ms); + check(!popup->isVisible(), "popup must hide when closing finishes"); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + check(popup.isNull(), "closed popup must be deleted"); +} +} // namespace + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + for (bool flipped : {false, true}) + for (int dismissal = 0; dismissal < 4; ++dismissal) + for (bool interrupt : {false, true}) exercise(flipped, dismissal, interrupt); + std::cout << "16 popup scenarios; failures=" << failures << '\n'; + return failures ? 1 : 0; +} diff --git a/tests/test_qt/test_section_animation/CMakeLists.txt b/tests/test_qt/test_section_animation/CMakeLists.txt new file mode 100644 index 0000000..9ec01a9 --- /dev/null +++ b/tests/test_qt/test_section_animation/CMakeLists.txt @@ -0,0 +1,2 @@ +add_executable(test_section_animation main.cpp) +target_link_libraries(test_section_animation meta_qt) diff --git a/tests/test_qt/test_section_animation/main.cpp b/tests/test_qt/test_section_animation/main.cpp new file mode 100644 index 0000000..696a005 --- /dev/null +++ b/tests/test_qt/test_section_animation/main.cpp @@ -0,0 +1,173 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "meta_qt/designs/industrial/section.hpp" + +using meta::qt::industrial::Section; + +namespace +{ +void flush() +{ + for (int i = 0; i < 8; ++i) + { + QCoreApplication::sendPostedEvents(); + QCoreApplication::processEvents(); + } +} + +// Observe actual paint events as well as settled geometry: a transient bad +// layout must not be hidden by draining several rounds of LayoutRequest first. +class PanelCheck : public QObject +{ +public: + std::array
sections{}; + int failures = 0; + int paints = 0; + bool watching = false; + + void check() + { + for (int i = 0; i < 4; ++i) + { + auto *s = sections[i]; + if (s->height() != s->sizeHint().height()) + { + if (failures < 5) + std::cerr << "section=" << i << " actual=" << s->height() + << " hint=" << s->sizeHint().height() << '\n'; + fail("section height differs from its requested reveal"); + } + const auto *layout = s->parentWidget()->layout(); + const int expected_y = i + ? sections[i - 1]->geometry().bottom() + 1 + + layout->spacing() + : layout->contentsMargins().top(); + if (s->y() != expected_y) fail("section position or gap changed"); + } + } + + void fail(const char *message) + { + if (failures++ < 5) std::cerr << message << '\n'; + } + + bool eventFilter(QObject *, QEvent *event) override + { + if (watching && event->type() == QEvent::Paint) + { + ++paints; + check(); + } + return false; + } +}; + +int exercise(int viewport_height) +{ + meta::qt::Theme theme; + QScrollArea scroll; + scroll.setAttribute(Qt::WA_DontShowOnScreen); + scroll.setWidgetResizable(true); + scroll.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + scroll.resize(520, viewport_height); + auto *container = new QWidget; + auto *outer = new QVBoxLayout(container); + outer->setAlignment(Qt::AlignTop); + auto *node = new QWidget; + auto *node_layout = new QVBoxLayout(node); + node_layout->setAlignment(Qt::AlignTop); + outer->addWidget(node); + auto *page = new QWidget; + auto *layout = new QVBoxLayout(page); + layout->setAlignment(Qt::AlignTop); + node_layout->addWidget(page); + + PanelCheck checker; + const std::array rows{3, 3, 2, 8}; + for (int i = 0; i < 4; ++i) + { + auto *s = new Section(QString::number(i), theme); + checker.sections[i] = s; + for (int j = 0; j < rows[i]; ++j) + { + auto *row = new QLabel(QString("Parameter %1").arg(j)); + row->setFixedHeight(36); + s->content_layout->addWidget(row); + } + layout->addWidget(s); + s->set_expanded(i != 3); + s->installEventFilter(&checker); + } + scroll.setWidget(container); + scroll.show(); + flush(); + checker.watching = true; + + auto advance = [&](Section *s, int ms) + { + s->findChild()->setCurrentTime(ms); + flush(); + checker.check(); + }; + auto *last = checker.sections.back(); + for (bool expanded : {true, false}) + { + last->set_expanded(expanded); + for (int ms = 0; ms <= theme.metrics.section_ms; ms += 16) + advance(last, ms); + advance(last, theme.metrics.section_ms); + } + + // Reverse each section while the last card is collapsed. The new transition + // must start at the displayed height, including repeated same-state calls. + for (int i = 0; i < 4; ++i) + { + auto *s = checker.sections[i]; + const bool initially_open = i != 3; + s->set_expanded(!initially_open); + advance(s, 48); + const int before = s->sizeHint().height(); + s->set_expanded(initially_open); + if (s->sizeHint().height() != before) + checker.fail("reversing animation jumps to an endpoint"); + advance(s, 32); + const int repeated = s->sizeHint().height(); + s->set_expanded(initially_open); + if (s->sizeHint().height() != repeated) + checker.fail("same-state request jumps to an endpoint"); + advance(s, theme.metrics.section_ms); + } + + // Fully open sections must still follow body changes after animation ends. + auto *extra = new QLabel("Added parameter"); + extra->setFixedHeight(36); + const int old_height = checker.sections[0]->height(); + checker.sections[0]->content_layout->addWidget(extra); + flush(); + checker.check(); + if (checker.sections[0]->height() <= old_height) + checker.fail("expanded section did not follow new content"); + + std::cout << "viewport=" << viewport_height << " paints=" << checker.paints + << " failures=" << checker.failures << '\n'; + checker.watching = false; + return checker.failures; +} +} // namespace + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + int failures = 0; + for (int height : {1100, 740, 400}) failures += exercise(height); + return failures ? 1 : 0; +} + From d962712be60812a92fa83dec485aedac71405677 Mon Sep 17 00:00:00 2001 From: Otto Link Date: Sun, 6 Sep 2026 09:15:57 +0200 Subject: [PATCH 3/3] chore: format code --- .../ext/color_gradient/color_gradient.hpp | 15 +- .../ext/color_gradient/gradient_library.hpp | 42 +- .../ext/color_gradient/gradient_metrics.hpp | 3 +- .../ext/color_gradient/gradient_library.cpp | 360 +++++++----- .../ext/color_gradient/gradient_metrics.cpp | 87 +-- .../meta_qt/designs/industrial/combo.hpp | 4 +- .../meta_qt/widgets/gradient_picker.hpp | 60 +- MetaUI/qt/src/designs/industrial/combo.cpp | 20 +- .../qt/src/designs/industrial/industrial.cpp | 3 +- MetaUI/qt/src/designs/industrial/section.cpp | 15 +- MetaUI/qt/src/widgets/gradient_picker.cpp | 550 +++++++++++------- tests/test_qt/gradient_picker_snap/main.cpp | 42 +- tests/test_qt/test_combo_animation/main.cpp | 41 +- tests/test_qt/test_section_animation/main.cpp | 25 +- tests/unittests/test_gradient_library.cpp | 59 +- tests/unittests/test_gradient_metrics.cpp | 31 +- tests/unittests/test_gradient_picker.cpp | 74 ++- 17 files changed, 841 insertions(+), 590 deletions(-) diff --git a/Meta/include/meta/ext/color_gradient/color_gradient.hpp b/Meta/include/meta/ext/color_gradient/color_gradient.hpp index f6cb69a..8940dd4 100644 --- a/Meta/include/meta/ext/color_gradient/color_gradient.hpp +++ b/Meta/include/meta/ext/color_gradient/color_gradient.hpp @@ -8,10 +8,12 @@ #include -namespace meta { +namespace meta +{ /// A color stop in a gradient. -struct Stop { +struct Stop +{ /// Position in the range [0, 1]. float position; @@ -22,7 +24,8 @@ struct Stop { }; /// A named color gradient preset. -struct Preset { +struct Preset +{ /// Preset name. std::string name; @@ -36,7 +39,8 @@ struct Preset { /// type: they are host configuration, carried in attribute metadata as a /// GradientPresets entry (keys::ui::presets), so that deserializing a value /// cannot clobber the preset library installed at setup time. -class ColorGradient { +class ColorGradient +{ public: /// Constructs a default black-to-white gradient. ColorGradient() = default; @@ -72,7 +76,8 @@ class ColorGradient { /// Preset library for a gradient attribute, installed by the host into /// attribute metadata under keys::ui::presets. Runtime configuration, not /// document state: never serialized (mirrors meta::DataProvider). -struct GradientPresets { +struct GradientPresets +{ /// Available presets. std::vector presets; }; diff --git a/Meta/include/meta/ext/color_gradient/gradient_library.hpp b/Meta/include/meta/ext/color_gradient/gradient_library.hpp index 8e05c93..99f0202 100644 --- a/Meta/include/meta/ext/color_gradient/gradient_library.hpp +++ b/Meta/include/meta/ext/color_gradient/gradient_library.hpp @@ -14,10 +14,12 @@ #include "meta/core/event.hpp" #include "meta/ext/color_gradient/color_gradient.hpp" -namespace meta { +namespace meta +{ /// Ordering applied to preset grids. Favourites are always pinned first. -enum class GradientSort { +enum class GradientSort +{ Default, ///< host order, then library insertion order Name, ///< case-insensitive name Luminance, ///< dark to light (gradient_luminance) @@ -31,11 +33,12 @@ std::string_view to_string(GradientSort sort); std::optional gradient_sort_from_string(std::string_view text); /// Outcome of GradientLibrary::import_file(). -struct GradientImportReport { +struct GradientImportReport +{ std::size_t added = 0; ///< stored under their own name std::size_t renamed = 0; ///< stored under a suffixed name (name clash) std::size_t skipped = 0; ///< identical to an existing preset - bool ok = false; ///< file could be read and held gradients + bool ok = false; ///< file could be read and held gradients }; /** @@ -61,9 +64,9 @@ nlohmann::json gradient_file_json(const std::vector &presets); * * @return The parsed presets, or std::nullopt when nothing usable was found. */ -std::optional> -parse_gradient_file(const nlohmann::json &json, - std::string_view fallback_name = "Gradient"); +std::optional> parse_gradient_file( + const nlohmann::json &json, + std::string_view fallback_name = "Gradient"); /** * @brief The user's gradient presets, shared by every gradient widget in the @@ -78,7 +81,8 @@ parse_gradient_file(const nlohmann::json &json, * The Qt GradientPicker assigns a default per-user path on first use when * none is set; call set_path() + load() beforehand to choose another. */ -class GradientLibrary { +class GradientLibrary +{ public: /// Fired after every effective mutation and after a successful load. Event<> changed; @@ -113,8 +117,8 @@ class GradientLibrary { // --- user presets const std::vector &presets() const; - bool has(std::string_view name) const; - const Preset *find(std::string_view name) const; + bool has(std::string_view name) const; + const Preset *find(std::string_view name) const; /** * @brief Stores a preset. The name is trimmed ("Gradient" when empty) and @@ -138,19 +142,19 @@ class GradientLibrary { /// `base` if free, otherwise "base (2)", "base (3)", ...; names in /// `reserved` (e.g. host presets) count as taken. - std::string unique_name(std::string_view base, + std::string unique_name(std::string_view base, const std::vector &reserved = {}) const; // --- favourites - bool is_favorite(std::string_view name) const; - void set_favorite(std::string_view name, bool on); + bool is_favorite(std::string_view name) const; + void set_favorite(std::string_view name, bool on); const std::vector &favorites() const; // --- sort preference GradientSort sort() const; - void set_sort(GradientSort sort); + void set_sort(GradientSort sort); // --- serialization @@ -171,16 +175,16 @@ class GradientLibrary { /// Writes `presets` as a gradient file (no favourites, no sort). bool export_file(const std::filesystem::path &path, - const std::vector &presets) const; + const std::vector &presets) const; private: void on_modified(); - std::vector presets_; + std::vector presets_; std::vector favorites_; - GradientSort sort_ = GradientSort::Default; - std::filesystem::path path_; - bool autosave_ = true; + GradientSort sort_ = GradientSort::Default; + std::filesystem::path path_; + bool autosave_ = true; }; } // namespace meta diff --git a/Meta/include/meta/ext/color_gradient/gradient_metrics.hpp b/Meta/include/meta/ext/color_gradient/gradient_metrics.hpp index 6463915..c280026 100644 --- a/Meta/include/meta/ext/color_gradient/gradient_metrics.hpp +++ b/Meta/include/meta/ext/color_gradient/gradient_metrics.hpp @@ -7,7 +7,8 @@ #include "meta/ext/color_gradient/color_gradient.hpp" -namespace meta { +namespace meta +{ /** * @brief Colour of a gradient at position `t`. diff --git a/Meta/src/ext/color_gradient/gradient_library.cpp b/Meta/src/ext/color_gradient/gradient_library.cpp index a0ad395..bf17d6b 100644 --- a/Meta/src/ext/color_gradient/gradient_library.cpp +++ b/Meta/src/ext/color_gradient/gradient_library.cpp @@ -8,41 +8,45 @@ #include "meta/ext/color_gradient/gradient_library.hpp" #include "meta/logger.hpp" -namespace meta { +namespace meta +{ -namespace { +namespace +{ constexpr char kFileFormat[] = "meta.gradients"; -constexpr int kFileVersion = 1; +constexpr int kFileVersion = 1; constexpr char kDefaultName[] = "Gradient"; -std::string trim(std::string_view text) { +std::string trim(std::string_view text) +{ const auto not_space = [](unsigned char c) { return !std::isspace(c); }; const auto begin = std::find_if(text.begin(), text.end(), not_space); const auto end = std::find_if(text.rbegin(), text.rend(), not_space).base(); return begin < end ? std::string(begin, end) : std::string(); } -void sort_stops(std::vector &stops) { - std::stable_sort( - stops.begin(), stops.end(), - [](const Stop &a, const Stop &b) { return a.position < b.position; }); +void sort_stops(std::vector &stops) +{ + std::stable_sort(stops.begin(), + stops.end(), + [](const Stop &a, const Stop &b) + { return a.position < b.position; }); } -bool parse_color(const nlohmann::json &json, std::array &out) { - if (!json.is_array() || json.size() < 3) - return false; +bool parse_color(const nlohmann::json &json, std::array &out) +{ + if (!json.is_array() || json.size() < 3) return false; - const std::size_t n = std::min(4, json.size()); + const std::size_t n = std::min(4, json.size()); std::array color = {0.f, 0.f, 0.f, 1.f}; - bool over_one = false; + bool over_one = false; - for (std::size_t k = 0; k < n; ++k) { - if (!json[k].is_number()) - return false; + for (std::size_t k = 0; k < n; ++k) + { + if (!json[k].is_number()) return false; color[k] = json[k].get(); - if (color[k] > 1.f) - over_one = true; + if (color[k] > 1.f) over_one = true; } // 0-255 encoded colours (e.g. Hesiod's data/color_gradient.json) @@ -58,9 +62,9 @@ bool parse_color(const nlohmann::json &json, std::array &out) { } std::optional parse_preset(const nlohmann::json &json, - std::string_view fallback_name) { - if (!json.is_object()) - return std::nullopt; + std::string_view fallback_name) +{ + if (!json.is_object()) return std::nullopt; const nlohmann::json *stops_json = nullptr; if (json.contains("stops") && json["stops"].is_array()) @@ -68,69 +72,75 @@ std::optional parse_preset(const nlohmann::json &json, else if (json.contains("value") && json["value"].is_array()) stops_json = &json["value"]; - if (!stops_json) - return std::nullopt; + if (!stops_json) return std::nullopt; Preset preset; if (json.contains("name") && json["name"].is_string()) preset.name = trim(json["name"].get()); - if (preset.name.empty()) - preset.name = std::string(fallback_name); + if (preset.name.empty()) preset.name = std::string(fallback_name); - for (const auto &s : *stops_json) { + for (const auto &s : *stops_json) + { if (!s.is_object() || !s.contains("position") || !s["position"].is_number() || !s.contains("color")) continue; std::array color; - if (!parse_color(s["color"], color)) - continue; + if (!parse_color(s["color"], color)) continue; preset.stops.push_back( {std::clamp(s["position"].get(), 0.f, 1.f), color}); } - if (preset.stops.size() < 2) - return std::nullopt; + if (preset.stops.size() < 2) return std::nullopt; sort_stops(preset.stops); return preset; } std::vector parse_preset_list(const nlohmann::json &array, - std::string_view fallback_name) { + std::string_view fallback_name) +{ std::vector out; - std::size_t index = 0; + std::size_t index = 0; - for (const auto &g : array) { + for (const auto &g : array) + { ++index; - const std::string fallback = - array.size() > 1 - ? std::string(fallback_name) + " " + std::to_string(index) - : std::string(fallback_name); + const std::string fallback = array.size() > 1 + ? std::string(fallback_name) + " " + + std::to_string(index) + : std::string(fallback_name); if (auto preset = parse_preset(g, fallback)) out.push_back(std::move(*preset)); else Logger::log()->warn( - "parse_gradient_file: skipping invalid gradient entry #{}", index); + "parse_gradient_file: skipping invalid gradient entry #{}", + index); } return out; } -bool read_json_file(const std::filesystem::path &path, nlohmann::json &out) { +bool read_json_file(const std::filesystem::path &path, nlohmann::json &out) +{ std::ifstream file(path); - if (!file) { + if (!file) + { Logger::log()->error("GradientLibrary: cannot open '{}'", path.string()); return false; } - try { + try + { file >> out; - } catch (const std::exception &e) { + } + catch (const std::exception &e) + { Logger::log()->error("GradientLibrary: cannot parse '{}': {}", - path.string(), e.what()); + path.string(), + e.what()); return false; } @@ -138,13 +148,15 @@ bool read_json_file(const std::filesystem::path &path, nlohmann::json &out) { } bool write_json_file(const std::filesystem::path &path, - const nlohmann::json &json) { + const nlohmann::json &json) +{ std::error_code ec; if (path.has_parent_path()) std::filesystem::create_directories(path.parent_path(), ec); std::ofstream file(path); - if (!file) { + if (!file) + { Logger::log()->error("GradientLibrary: cannot write '{}'", path.string()); return false; } @@ -159,35 +171,37 @@ bool write_json_file(const std::filesystem::path &path, // Free functions // --------------------------------------------------------------------------- -std::string_view to_string(GradientSort sort) { - switch (sort) { - case GradientSort::Name: - return "name"; - case GradientSort::Luminance: - return "luminance"; - case GradientSort::Hue: - return "hue"; +std::string_view to_string(GradientSort sort) +{ + switch (sort) + { + case GradientSort::Name: return "name"; + case GradientSort::Luminance: return "luminance"; + case GradientSort::Hue: return "hue"; case GradientSort::Default: - default: - return "default"; + default: return "default"; } } -std::optional gradient_sort_from_string(std::string_view text) { - for (GradientSort s : {GradientSort::Default, GradientSort::Name, - GradientSort::Luminance, GradientSort::Hue}) - if (text == to_string(s)) - return s; +std::optional gradient_sort_from_string(std::string_view text) +{ + for (GradientSort s : {GradientSort::Default, + GradientSort::Name, + GradientSort::Luminance, + GradientSort::Hue}) + if (text == to_string(s)) return s; return std::nullopt; } -nlohmann::json gradient_file_json(const std::vector &presets) { +nlohmann::json gradient_file_json(const std::vector &presets) +{ nlohmann::json json; json["format"] = kFileFormat; json["version"] = kFileVersion; json["gradients"] = nlohmann::json::array(); - for (const auto &preset : presets) { + for (const auto &preset : presets) + { nlohmann::json g; g["name"] = preset.name; g["stops"] = nlohmann::json::array(); @@ -199,9 +213,10 @@ nlohmann::json gradient_file_json(const std::vector &presets) { return json; } -std::optional> -parse_gradient_file(const nlohmann::json &json, - std::string_view fallback_name) { +std::optional> parse_gradient_file( + const nlohmann::json &json, + std::string_view fallback_name) +{ std::vector out; if (json.is_array()) @@ -212,8 +227,7 @@ parse_gradient_file(const nlohmann::json &json, else if (auto preset = parse_preset(json, fallback_name)) out.push_back(std::move(*preset)); - if (out.empty()) - return std::nullopt; + if (out.empty()) return std::nullopt; return out; } @@ -221,12 +235,14 @@ parse_gradient_file(const nlohmann::json &json, // GradientLibrary // --------------------------------------------------------------------------- -GradientLibrary &GradientLibrary::instance() { +GradientLibrary &GradientLibrary::instance() +{ static GradientLibrary library; return library; } -void GradientLibrary::set_path(std::filesystem::path path) { +void GradientLibrary::set_path(std::filesystem::path path) +{ path_ = std::move(path); } @@ -236,36 +252,42 @@ void GradientLibrary::set_autosave(bool on) { autosave_ = on; } bool GradientLibrary::autosave() const { return autosave_; } -bool GradientLibrary::load() { - if (path_.empty()) { +bool GradientLibrary::load() +{ + if (path_.empty()) + { Logger::log()->warn("GradientLibrary::load: no path set"); return false; } std::error_code ec; - if (!std::filesystem::exists(path_, ec)) { + if (!std::filesystem::exists(path_, ec)) + { Logger::log()->trace("GradientLibrary::load: no file at '{}'", path_.string()); return false; } nlohmann::json json; - if (!read_json_file(path_, json)) - return false; + if (!read_json_file(path_, json)) return false; - if (!json_from(json)) { + if (!json_from(json)) + { Logger::log()->warn("GradientLibrary::load: '{}' is not a gradient library", path_.string()); return false; } Logger::log()->trace("GradientLibrary::load: {} presets from '{}'", - presets_.size(), path_.string()); + presets_.size(), + path_.string()); return true; } -bool GradientLibrary::save() const { - if (path_.empty()) { +bool GradientLibrary::save() const +{ + if (path_.empty()) + { Logger::log()->trace("GradientLibrary::save: no path set, skipping"); return false; } @@ -274,18 +296,22 @@ bool GradientLibrary::save() const { const std::vector &GradientLibrary::presets() const { return presets_; } -bool GradientLibrary::has(std::string_view name) const { +bool GradientLibrary::has(std::string_view name) const +{ return find(name) != nullptr; } -const Preset *GradientLibrary::find(std::string_view name) const { - const auto it = - std::find_if(presets_.begin(), presets_.end(), - [name](const Preset &p) { return p.name == name; }); +const Preset *GradientLibrary::find(std::string_view name) const +{ + const auto it = std::find_if(presets_.begin(), + presets_.end(), + [name](const Preset &p) + { return p.name == name; }); return it == presets_.end() ? nullptr : &*it; } -std::string GradientLibrary::add(Preset preset) { +std::string GradientLibrary::add(Preset preset) +{ preset.name = unique_name(preset.name); sort_stops(preset.stops); @@ -297,12 +323,13 @@ std::string GradientLibrary::add(Preset preset) { return name; } -bool GradientLibrary::update(std::string_view name, std::vector stops) { - const auto it = - std::find_if(presets_.begin(), presets_.end(), - [name](const Preset &p) { return p.name == name; }); - if (it == presets_.end()) - return false; +bool GradientLibrary::update(std::string_view name, std::vector stops) +{ + const auto it = std::find_if(presets_.begin(), + presets_.end(), + [name](const Preset &p) + { return p.name == name; }); + if (it == presets_.end()) return false; sort_stops(stops); it->stops = std::move(stops); @@ -312,38 +339,38 @@ bool GradientLibrary::update(std::string_view name, std::vector stops) { return true; } -bool GradientLibrary::rename(std::string_view from, std::string_view to) { +bool GradientLibrary::rename(std::string_view from, std::string_view to) +{ const std::string new_name = trim(to); - const auto it = - std::find_if(presets_.begin(), presets_.end(), - [from](const Preset &p) { return p.name == from; }); - if (it == presets_.end() || new_name.empty()) - return false; - if (new_name == it->name) - return true; - if (has(new_name)) - return false; + const auto it = std::find_if(presets_.begin(), + presets_.end(), + [from](const Preset &p) + { return p.name == from; }); + if (it == presets_.end() || new_name.empty()) return false; + if (new_name == it->name) return true; + if (has(new_name)) return false; const std::string old_name = it->name; it->name = new_name; for (auto &favorite : favorites_) - if (favorite == old_name) - favorite = new_name; + if (favorite == old_name) favorite = new_name; - Logger::log()->trace("GradientLibrary::rename: '{}' -> '{}'", old_name, + Logger::log()->trace("GradientLibrary::rename: '{}' -> '{}'", + old_name, new_name); on_modified(); return true; } -bool GradientLibrary::remove(std::string_view name) { - const auto it = - std::find_if(presets_.begin(), presets_.end(), - [name](const Preset &p) { return p.name == name; }); - if (it == presets_.end()) - return false; +bool GradientLibrary::remove(std::string_view name) +{ + const auto it = std::find_if(presets_.begin(), + presets_.end(), + [name](const Preset &p) + { return p.name == name; }); + if (it == presets_.end()) return false; const std::string removed = it->name; // `name` may alias it->name presets_.erase(it); @@ -355,45 +382,48 @@ bool GradientLibrary::remove(std::string_view name) { return true; } -void GradientLibrary::clear() { +void GradientLibrary::clear() +{ Logger::log()->trace("GradientLibrary::clear ({} presets)", presets_.size()); presets_.clear(); favorites_.clear(); on_modified(); } -std::string -GradientLibrary::unique_name(std::string_view base, - const std::vector &reserved) const { +std::string GradientLibrary::unique_name( + std::string_view base, + const std::vector &reserved) const +{ std::string name = trim(base); - if (name.empty()) - name = kDefaultName; + if (name.empty()) name = kDefaultName; - const auto taken = [&](const std::string &candidate) { - return has(candidate) || std::find(reserved.begin(), reserved.end(), - candidate) != reserved.end(); + const auto taken = [&](const std::string &candidate) + { + return has(candidate) || + std::find(reserved.begin(), reserved.end(), candidate) != + reserved.end(); }; - if (!taken(name)) - return name; + if (!taken(name)) return name; - for (int i = 2;; ++i) { + for (int i = 2;; ++i) + { const std::string candidate = name + " (" + std::to_string(i) + ")"; - if (!taken(candidate)) - return candidate; + if (!taken(candidate)) return candidate; } } -bool GradientLibrary::is_favorite(std::string_view name) const { +bool GradientLibrary::is_favorite(std::string_view name) const +{ return std::find(favorites_.begin(), favorites_.end(), name) != favorites_.end(); } -void GradientLibrary::set_favorite(std::string_view name, bool on) { +void GradientLibrary::set_favorite(std::string_view name, bool on) +{ const auto it = std::find(favorites_.begin(), favorites_.end(), name); const bool present = it != favorites_.end(); - if (on == present) - return; + if (on == present) return; if (on) favorites_.emplace_back(name); @@ -404,39 +434,42 @@ void GradientLibrary::set_favorite(std::string_view name, bool on) { on_modified(); } -const std::vector &GradientLibrary::favorites() const { +const std::vector &GradientLibrary::favorites() const +{ return favorites_; } GradientSort GradientLibrary::sort() const { return sort_; } -void GradientLibrary::set_sort(GradientSort sort) { - if (sort == sort_) - return; +void GradientLibrary::set_sort(GradientSort sort) +{ + if (sort == sort_) return; sort_ = sort; on_modified(); } -nlohmann::json GradientLibrary::json_to() const { +nlohmann::json GradientLibrary::json_to() const +{ nlohmann::json json = gradient_file_json(presets_); json["favorites"] = favorites_; json["sort"] = std::string(to_string(sort_)); return json; } -bool GradientLibrary::json_from(const nlohmann::json &json) { +bool GradientLibrary::json_from(const nlohmann::json &json) +{ if (!json.is_object() || !json.contains("gradients") || !json["gradients"].is_array()) return false; - std::vector presets = - parse_preset_list(json["gradients"], kDefaultName); + std::vector presets = parse_preset_list(json["gradients"], + kDefaultName); std::vector favorites; if (json.contains("favorites") && json["favorites"].is_array()) - for (const auto &f : json["favorites"]) { - if (!f.is_string()) - continue; + for (const auto &f : json["favorites"]) + { + if (!f.is_string()) continue; const std::string name = f.get(); if (std::find(favorites.begin(), favorites.end(), name) == favorites.end()) @@ -445,12 +478,13 @@ bool GradientLibrary::json_from(const nlohmann::json &json) { GradientSort sort = GradientSort::Default; if (json.contains("sort") && json["sort"].is_string()) - if (const auto s = - gradient_sort_from_string(json["sort"].get())) + if (const auto s = gradient_sort_from_string( + json["sort"].get())) sort = *s; presets_.clear(); - for (auto &preset : presets) { + for (auto &preset : presets) + { preset.name = unique_name(preset.name); presets_.push_back(std::move(preset)); } @@ -461,30 +495,36 @@ bool GradientLibrary::json_from(const nlohmann::json &json) { return true; } -GradientImportReport -GradientLibrary::import_file(const std::filesystem::path &path) { +GradientImportReport GradientLibrary::import_file( + const std::filesystem::path &path) +{ GradientImportReport report; nlohmann::json json; - if (!read_json_file(path, json)) - return report; + if (!read_json_file(path, json)) return report; const auto parsed = parse_gradient_file(json, path.stem().string()); - if (!parsed) { + if (!parsed) + { Logger::log()->warn("GradientLibrary::import_file: no gradients in '{}'", path.string()); return report; } - for (Preset preset : *parsed) { - if (const Preset *existing = find(preset.name)) { - if (existing->stops == preset.stops) { + for (Preset preset : *parsed) + { + if (const Preset *existing = find(preset.name)) + { + if (existing->stops == preset.stops) + { ++report.skipped; continue; } preset.name = unique_name(preset.name); ++report.renamed; - } else { + } + else + { ++report.added; } presets_.push_back(std::move(preset)); @@ -494,23 +534,27 @@ GradientLibrary::import_file(const std::filesystem::path &path) { Logger::log()->trace( "GradientLibrary::import_file: '{}': {} added, {} renamed, {} skipped", - path.string(), report.added, report.renamed, report.skipped); + path.string(), + report.added, + report.renamed, + report.skipped); - if (report.added + report.renamed > 0) - on_modified(); + if (report.added + report.renamed > 0) on_modified(); return report; } bool GradientLibrary::export_file(const std::filesystem::path &path, - const std::vector &presets) const { + const std::vector &presets) const +{ Logger::log()->trace("GradientLibrary::export_file: {} presets to '{}'", - presets.size(), path.string()); + presets.size(), + path.string()); return write_json_file(path, gradient_file_json(presets)); } -void GradientLibrary::on_modified() { - if (autosave_ && !path_.empty()) - save(); +void GradientLibrary::on_modified() +{ + if (autosave_ && !path_.empty()) save(); changed.notify(); } diff --git a/Meta/src/ext/color_gradient/gradient_metrics.cpp b/Meta/src/ext/color_gradient/gradient_metrics.cpp index 0304e7b..9755fa3 100644 --- a/Meta/src/ext/color_gradient/gradient_metrics.cpp +++ b/Meta/src/ext/color_gradient/gradient_metrics.cpp @@ -7,30 +7,33 @@ #include "meta/ext/color_gradient/gradient_metrics.hpp" -namespace meta { +namespace meta +{ -namespace { +namespace +{ -std::vector sorted_stops(const std::vector &stops) { +std::vector sorted_stops(const std::vector &stops) +{ std::vector sorted = stops; - std::stable_sort( - sorted.begin(), sorted.end(), - [](const Stop &a, const Stop &b) { return a.position < b.position; }); + std::stable_sort(sorted.begin(), + sorted.end(), + [](const Stop &a, const Stop &b) + { return a.position < b.position; }); return sorted; } // Expects sorted, non-empty stops. -std::array sample_sorted(const std::vector &sorted, float t) { +std::array sample_sorted(const std::vector &sorted, float t) +{ t = std::clamp(t, 0.f, 1.f); - if (t <= sorted.front().position) - return sorted.front().color; - if (t >= sorted.back().position) - return sorted.back().color; + if (t <= sorted.front().position) return sorted.front().color; + if (t >= sorted.back().position) return sorted.back().color; - for (std::size_t i = 1; i < sorted.size(); ++i) { - if (t > sorted[i].position) - continue; + for (std::size_t i = 1; i < sorted.size(); ++i) + { + if (t > sorted[i].position) continue; const Stop &a = sorted[i - 1]; const Stop &b = sorted[i]; @@ -46,12 +49,14 @@ std::array sample_sorted(const std::vector &sorted, float t) { return sorted.back().color; } -float sample_position(int i, int samples) { +float sample_position(int i, int samples) +{ return samples == 1 ? 0.5f : float(i) / float(samples - 1); } // HSV hue in degrees [0, 360) and saturation in [0, 1]. -void hue_saturation(const std::array &c, float &hue, float &sat) { +void hue_saturation(const std::array &c, float &hue, float &sat) +{ const float r = std::clamp(c[0], 0.f, 1.f); const float g = std::clamp(c[1], 0.f, 1.f); const float b = std::clamp(c[2], 0.f, 1.f); @@ -61,8 +66,7 @@ void hue_saturation(const std::array &c, float &hue, float &sat) { hue = 0.f; sat = mx > 0.f ? d / mx : 0.f; - if (d <= 1e-6f) - return; + if (d <= 1e-6f) return; float h; if (mx == r) @@ -73,27 +77,27 @@ void hue_saturation(const std::array &c, float &hue, float &sat) { h = (r - g) / d + 4.f; h *= 60.f; - if (h < 0.f) - h += 360.f; + if (h < 0.f) h += 360.f; hue = h; } } // namespace -std::array sample_gradient(const std::vector &stops, float t) { - if (stops.empty()) - return {0.f, 0.f, 0.f, 1.f}; +std::array sample_gradient(const std::vector &stops, float t) +{ + if (stops.empty()) return {0.f, 0.f, 0.f, 1.f}; return sample_sorted(sorted_stops(stops), t); } -float gradient_luminance(const std::vector &stops, int samples) { - if (stops.empty() || samples <= 0) - return 0.f; +float gradient_luminance(const std::vector &stops, int samples) +{ + if (stops.empty() || samples <= 0) return 0.f; const std::vector sorted = sorted_stops(stops); - float sum = 0.f; + float sum = 0.f; - for (int i = 0; i < samples; ++i) { + for (int i = 0; i < samples; ++i) + { const auto c = sample_sorted(sorted, sample_position(i, samples)); sum += 0.2126f * std::clamp(c[0], 0.f, 1.f) + 0.7152f * std::clamp(c[1], 0.f, 1.f) + @@ -103,19 +107,21 @@ float gradient_luminance(const std::vector &stops, int samples) { return sum / float(samples); } -float gradient_hue(const std::vector &stops, int samples) { - if (stops.empty() || samples <= 0) - return -1.f; +float gradient_hue(const std::vector &stops, int samples) +{ + if (stops.empty() || samples <= 0) return -1.f; const std::vector sorted = sorted_stops(stops); - double sx = 0.0; - double sy = 0.0; - double weight = 0.0; + double sx = 0.0; + double sy = 0.0; + double weight = 0.0; - for (int i = 0; i < samples; ++i) { + for (int i = 0; i < samples; ++i) + { float hue = 0.f; float sat = 0.f; - hue_saturation(sample_sorted(sorted, sample_position(i, samples)), hue, + hue_saturation(sample_sorted(sorted, sample_position(i, samples)), + hue, sat); const double rad = double(hue) * std::numbers::pi / 180.0; @@ -124,14 +130,11 @@ float gradient_hue(const std::vector &stops, int samples) { weight += sat; } - if (weight < 1e-4) - return -1.f; + if (weight < 1e-4) return -1.f; double deg = std::atan2(sy, sx) * 180.0 / std::numbers::pi; - if (deg < 0.0) - deg += 360.0; - if (deg >= 360.0) - deg -= 360.0; + if (deg < 0.0) deg += 360.0; + if (deg >= 360.0) deg -= 360.0; return float(deg); } diff --git a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp index 7f0e559..27607db 100644 --- a/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp +++ b/MetaUI/qt/include/meta_qt/designs/industrial/combo.hpp @@ -66,8 +66,8 @@ class ComboPopup : public QWidget void closeEvent(QCloseEvent *event) override; private: - int index_at(const QPoint &pos) const; - int row_height() const; + int index_at(const QPoint &pos) const; + int row_height() const; void animate_to(int height); /// Portion of the fixed-size window currently revealed by the animation. diff --git a/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp b/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp index 317437e..8620f41 100644 --- a/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp +++ b/MetaUI/qt/include/meta_qt/widgets/gradient_picker.hpp @@ -16,7 +16,8 @@ class QPixmap; class QScrollArea; class QToolButton; -namespace meta::qt { +namespace meta::qt +{ class PresetGridWidget; @@ -26,7 +27,8 @@ class PresetGridWidget; // Custom-painted gradient bar with interactive stop handles. // --------------------------------------------------------------------------- -class GradientBarWidget : public QWidget { +class GradientBarWidget : public QWidget +{ Q_OBJECT public: @@ -37,7 +39,7 @@ class GradientBarWidget : public QWidget { static constexpr int RADIUS = 4; explicit GradientBarWidget(std::vector &stops, - QWidget *parent = nullptr); + QWidget *parent = nullptr); void sort_stops(); @@ -56,11 +58,11 @@ class GradientBarWidget : public QWidget { private: QRectF bar_rect() const; QRectF stop_rect(const Stop &s) const; - int hit_test(const QPoint &pos) const; + int hit_test(const QPoint &pos) const; std::vector &stops_; - int selected_idx_ = -1; - bool dragging_ = false; + int selected_idx_ = -1; + bool dragging_ = false; }; // --------------------------------------------------------------------------- @@ -81,13 +83,14 @@ class GradientBarWidget : public QWidget { // height never squashes or clips the gradient visualization. // --------------------------------------------------------------------------- -class GradientPicker : public QWidget { +class GradientPicker : public QWidget +{ Q_OBJECT public: - explicit GradientPicker(std::vector &stops, + explicit GradientPicker(std::vector &stops, const std::vector &presets, - QWidget *parent = nullptr); + QWidget *parent = nullptr); // Called externally when the attribute's preset list changes. void set_presets(const std::vector &presets); @@ -109,23 +112,24 @@ class GradientPicker : public QWidget { void edit_ended(); // committed (drag release, colour picked, preset applied) protected: - void resizeEvent(QResizeEvent *e) override; - bool eventFilter(QObject *watched, QEvent *event) override; + void resizeEvent(QResizeEvent *e) override; + bool eventFilter(QObject *watched, QEvent *event) override; QSize sizeHint() const override; QSize minimumSizeHint() const override; private: - struct Entry { + struct Entry + { Preset preset; - bool user = false; // true: GradientLibrary entry, false: host preset + bool user = false; // true: GradientLibrary entry, false: host preset }; QWidget *build_toolbar(); - void schedule_rebuild(); - void rebuild_entries(); - void rebuild_preset_grid(); - QPixmap make_swatch(const Entry &entry, bool favorite) const; - void apply_stops(const std::vector &stops); + void schedule_rebuild(); + void rebuild_entries(); + void rebuild_preset_grid(); + QPixmap make_swatch(const Entry &entry, bool favorite) const; + void apply_stops(const std::vector &stops); std::vector host_names() const; @@ -134,20 +138,20 @@ class GradientPicker : public QWidget { void on_export_clicked(); void show_entry_menu(Entry entry, const QPoint &global_pos); void export_presets(const std::vector &presets, - const QString &suggested_file); + const QString &suggested_file); - std::vector &stops_; + std::vector &stops_; std::vector presets_; // host presets (attribute metadata) - std::vector entries_; // host + library, display order + std::vector entries_; // host + library, display order GradientBarWidget *bar_widget_ = nullptr; - QToolButton *save_button_ = nullptr; - QToolButton *import_button_ = nullptr; - QToolButton *export_button_ = nullptr; - QComboBox *sort_combo_ = nullptr; - QScrollArea *scroll_area_ = nullptr; - PresetGridWidget *preset_grid_ = nullptr; - bool rebuild_pending_ = false; + QToolButton *save_button_ = nullptr; + QToolButton *import_button_ = nullptr; + QToolButton *export_button_ = nullptr; + QComboBox *sort_combo_ = nullptr; + QScrollArea *scroll_area_ = nullptr; + 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 diff --git a/MetaUI/qt/src/designs/industrial/combo.cpp b/MetaUI/qt/src/designs/industrial/combo.cpp index 3a63d5a..78e9762 100644 --- a/MetaUI/qt/src/designs/industrial/combo.cpp +++ b/MetaUI/qt/src/designs/industrial/combo.cpp @@ -37,8 +37,8 @@ ComboPopup::ComboPopup(const Theme &theme, const QStringList &items, int current, QWidget *parent) - : QWidget(parent, Qt::Popup | Qt::FramelessWindowHint | - Qt::NoDropShadowWindowHint), + : QWidget(parent, + Qt::Popup | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint), theme_(&theme), items_(items), current_(current), @@ -57,14 +57,21 @@ ComboPopup::ComboPopup(const Theme &theme, animation_ = new QVariantAnimation(this); animation_->setDuration(theme_->metrics.section_ms); animation_->setEasingCurve(QEasingCurve::OutCubic); - connect(animation_, &QVariantAnimation::valueChanged, this, + connect(animation_, + &QVariantAnimation::valueChanged, + this, [this](const QVariant &value) { revealed_ = value.toInt(); update(); }); - connect(animation_, &QVariantAnimation::finished, this, - [this]() { if (closing_) close(); }); + connect(animation_, + &QVariantAnimation::finished, + this, + [this]() + { + if (closing_) close(); + }); } int ComboPopup::row_height() const { return kRowHeight; } @@ -132,7 +139,8 @@ QRect ComboPopup::card_rect() const int ComboPopup::index_at(const QPoint &pos) const { if (!card_rect().contains(pos) || pos.y() < kPopupPadding || - pos.y() >= full_height_ - kPopupPadding) return -1; + pos.y() >= full_height_ - kPopupPadding) + return -1; const int index = (pos.y() - kPopupPadding) / row_height(); return index >= 0 && index < items_.size() ? index : -1; diff --git a/MetaUI/qt/src/designs/industrial/industrial.cpp b/MetaUI/qt/src/designs/industrial/industrial.cpp index 188d7be..c1f72a9 100644 --- a/MetaUI/qt/src/designs/industrial/industrial.cpp +++ b/MetaUI/qt/src/designs/industrial/industrial.cpp @@ -36,7 +36,8 @@ void register_design() // // Resolved per section rather than captured, because the theme can be // set after the design registers. - return new Section(title, DesignRegistry::instance().theme(kDesignName)); + return new Section(title, + DesignRegistry::instance().theme(kDesignName)); }); // --- float: 58% of the rows in a Hesiod node panel diff --git a/MetaUI/qt/src/designs/industrial/section.cpp b/MetaUI/qt/src/designs/industrial/section.cpp index dbf5575..f9beaa8 100644 --- a/MetaUI/qt/src/designs/industrial/section.cpp +++ b/MetaUI/qt/src/designs/industrial/section.cpp @@ -167,7 +167,8 @@ void ClipBox::update_reveal_geometry() // before the scroll content learns its new minimum. Invalidate the whole // chain first, then allocate from the outside in before anything is painted. QList ancestors; - for (QWidget *widget = parentWidget(); widget; widget = widget->parentWidget()) + for (QWidget *widget = parentWidget(); widget; + widget = widget->parentWidget()) { if (auto *ancestor = widget->layout()) { @@ -177,13 +178,14 @@ void ClipBox::update_reveal_geometry() // The scroll content's minimum drives its viewport. Layouts outside that // scroll area do not need to be recalculated on every animation tick. QWidget *parent = widget->parentWidget(); - auto *scroll = parent - ? qobject_cast(parent->parentWidget()) - : nullptr; + auto *scroll = parent ? qobject_cast( + parent->parentWidget()) + : nullptr; if (scroll && scroll->viewport() == parent) break; if (widget->isWindow()) break; } - for (auto *ancestor : ancestors) ancestor->activate(); + for (auto *ancestor : ancestors) + ancestor->activate(); } QSize ClipBox::sizeHint() const @@ -365,7 +367,8 @@ void Section::set_expanded(bool new_state) return; } - // Reapplying state must not finish a transition that is already heading there. + // Reapplying state must not finish a transition that is already heading + // there. if (was_expanded == new_state) return; const int full = clip_->body_height(); diff --git a/MetaUI/qt/src/widgets/gradient_picker.cpp b/MetaUI/qt/src/widgets/gradient_picker.cpp index 05413a0..3d5eb73 100644 --- a/MetaUI/qt/src/widgets/gradient_picker.cpp +++ b/MetaUI/qt/src/widgets/gradient_picker.cpp @@ -33,21 +33,26 @@ #include "meta/logger.hpp" #include "meta_qt/widgets/gradient_picker.hpp" -namespace meta::qt { +namespace meta::qt +{ // --------------------------------------------------------------------------- // Colour conversion helpers // --------------------------------------------------------------------------- -static QColor to_qcolor(const std::array &c) { +static QColor to_qcolor(const std::array &c) +{ return QColor(int(std::clamp(c[0], 0.f, 1.f) * 255.f), int(std::clamp(c[1], 0.f, 1.f) * 255.f), int(std::clamp(c[2], 0.f, 1.f) * 255.f), int(std::clamp(c[3], 0.f, 1.f) * 255.f)); } -static std::array from_qcolor(const QColor &c) { - return {float(c.redF()), float(c.greenF()), float(c.blueF()), +static std::array from_qcolor(const QColor &c) +{ + return {float(c.redF()), + float(c.greenF()), + float(c.blueF()), float(c.alphaF())}; } @@ -57,17 +62,18 @@ static std::array from_qcolor(const QColor &c) { // Gives the process-wide GradientLibrary a per-user file on first use, unless // the host already chose one (set_path() before any picker exists). -static void ensure_gradient_library() { +static void ensure_gradient_library() +{ GradientLibrary &lib = GradientLibrary::instance(); - if (!lib.path().empty()) - return; + if (!lib.path().empty()) return; - QString dir = - QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + QString dir = QStandardPaths::writableLocation( + QStandardPaths::AppConfigLocation); if (dir.isEmpty()) dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); - if (dir.isEmpty()) { + if (dir.isEmpty()) + { static bool warned = false; if (!warned) Logger::log()->warn("GradientPicker: no writable config location, the " @@ -83,13 +89,16 @@ static void ensure_gradient_library() { lib.path().string()); } -static QString gradient_file_filter() { +static QString gradient_file_filter() +{ return QObject::tr("Gradient files (*.json);;All files (*)"); } -static void draw_star(QPainter &p, const QPointF ¢er, qreal radius) { +static void draw_star(QPainter &p, const QPointF ¢er, qreal radius) +{ QPolygonF star; - for (int i = 0; i < 10; ++i) { + for (int i = 0; i < 10; ++i) + { const qreal r = (i % 2 == 0) ? radius : radius * 0.45; const qreal a = -std::numbers::pi / 2.0 + i * std::numbers::pi / 5.0; star << QPointF(center.x() + r * std::cos(a), center.y() + r * std::sin(a)); @@ -104,22 +113,26 @@ static void draw_star(QPainter &p, const QPointF ¢er, qreal radius) { // --------------------------------------------------------------------------- GradientBarWidget::GradientBarWidget(std::vector &stops, QWidget *parent) - : QWidget(parent), stops_(stops) { + : QWidget(parent), stops_(stops) +{ setFixedHeight(TOTAL_H); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } -void GradientBarWidget::sort_stops() { - std::sort(stops_.begin(), stops_.end(), [](const Stop &a, const Stop &b) { - return a.position < b.position; - }); +void GradientBarWidget::sort_stops() +{ + std::sort(stops_.begin(), + stops_.end(), + [](const Stop &a, const Stop &b) + { return a.position < b.position; }); } -void GradientBarWidget::paintEvent(QPaintEvent *) { +void GradientBarWidget::paintEvent(QPaintEvent *) +{ QPainter p(this); p.setRenderHint(QPainter::Antialiasing); - const QRectF br = bar_rect(); + const QRectF br = bar_rect(); const QPalette &pal = palette(); // Gradient bar @@ -133,14 +146,15 @@ void GradientBarWidget::paintEvent(QPaintEvent *) { } // Stop handles - for (int i = 0; i < static_cast(stops_.size()); ++i) { + for (int i = 0; i < static_cast(stops_.size()); ++i) + { const QRectF r = stop_rect(stops_[i]); - const bool sel = (i == selected_idx_); + const bool sel = (i == selected_idx_); // Small triangle pointing up from bar bottom to handle const float cx = float(r.center().x()); const float ty = float(br.bottom()); - QPolygonF tri; + QPolygonF tri; tri << QPointF(cx - 4, ty + 8) << QPointF(cx + 4, ty + 8) << QPointF(cx, ty + 1); p.setPen(Qt::NoPen); @@ -156,39 +170,50 @@ void GradientBarWidget::paintEvent(QPaintEvent *) { } } -void GradientBarWidget::mouseDoubleClickEvent(QMouseEvent *e) { +void GradientBarWidget::mouseDoubleClickEvent(QMouseEvent *e) +{ const int idx = hit_test(e->pos()); - if (idx >= 0) { + if (idx >= 0) + { // Edit existing stop colour const QColor picked = QColorDialog::getColor( - to_qcolor(stops_[idx].color), this, QString(), + to_qcolor(stops_[idx].color), + this, + QString(), QColorDialog::ShowAlphaChannel | QColorDialog::DontUseNativeDialog); - if (picked.isValid()) { + if (picked.isValid()) + { stops_[idx].color = from_qcolor(picked); update(); Q_EMIT value_changed(); Q_EMIT edit_ended(); } - } else if (bar_rect().contains(QPointF(e->pos()))) { + } + else if (bar_rect().contains(QPointF(e->pos()))) + { // Add new stop - const double pos = std::clamp( - (e->pos().x() - bar_rect().left()) / bar_rect().width(), 0.0, 1.0); + const double pos = std::clamp((e->pos().x() - bar_rect().left()) / + bar_rect().width(), + 0.0, + 1.0); constexpr double eps = 1e-3; - const bool too_close = - std::any_of(stops_.begin(), stops_.end(), [&](const Stop &s) { - return std::abs(double(s.position) - pos) < eps; - }); - - if (!too_close) { + const bool too_close = std::any_of( + stops_.begin(), + stops_.end(), + [&](const Stop &s) + { return std::abs(double(s.position) - pos) < eps; }); + + if (!too_close) + { stops_.push_back({float(pos), {1.f, 1.f, 1.f, 1.f}}); sort_stops(); - auto it = - std::find_if(stops_.begin(), stops_.end(), [pos](const Stop &s) { - return s.position == float(pos); - }); + auto it = std::find_if(stops_.begin(), + stops_.end(), + [pos](const Stop &s) + { return s.position == float(pos); }); if (it != stops_.end()) selected_idx_ = static_cast(std::distance(stops_.begin(), it)); update(); @@ -198,36 +223,41 @@ void GradientBarWidget::mouseDoubleClickEvent(QMouseEvent *e) { } } -void GradientBarWidget::mousePressEvent(QMouseEvent *e) { - if (e->button() == Qt::LeftButton) { +void GradientBarWidget::mousePressEvent(QMouseEvent *e) +{ + if (e->button() == Qt::LeftButton) + { selected_idx_ = hit_test(e->pos()); dragging_ = selected_idx_ >= 0; update(); } } -void GradientBarWidget::mouseMoveEvent(QMouseEvent *e) { +void GradientBarWidget::mouseMoveEvent(QMouseEvent *e) +{ if (!dragging_ || selected_idx_ < 0 || selected_idx_ >= static_cast(stops_.size())) return; const QRectF br = bar_rect(); - if (br.width() <= 0) - return; + if (br.width() <= 0) return; - const double pos = - std::clamp((e->pos().x() - br.left()) / br.width(), 0.0, 1.0); + const double pos = std::clamp((e->pos().x() - br.left()) / br.width(), + 0.0, + 1.0); stops_[selected_idx_].position = float(pos); // Maintain sorted order while keeping selected_idx_ tracking the moved stop while (selected_idx_ > 0 && - stops_[selected_idx_].position < stops_[selected_idx_ - 1].position) { + stops_[selected_idx_].position < stops_[selected_idx_ - 1].position) + { std::swap(stops_[selected_idx_], stops_[selected_idx_ - 1]); --selected_idx_; } while (selected_idx_ + 1 < static_cast(stops_.size()) && - stops_[selected_idx_].position > stops_[selected_idx_ + 1].position) { + stops_[selected_idx_].position > stops_[selected_idx_ + 1].position) + { std::swap(stops_[selected_idx_], stops_[selected_idx_ + 1]); ++selected_idx_; } @@ -236,26 +266,28 @@ void GradientBarWidget::mouseMoveEvent(QMouseEvent *e) { Q_EMIT value_changed(); } -void GradientBarWidget::mouseReleaseEvent(QMouseEvent *) { - if (dragging_) { +void GradientBarWidget::mouseReleaseEvent(QMouseEvent *) +{ + if (dragging_) + { dragging_ = false; Q_EMIT edit_ended(); } } -void GradientBarWidget::contextMenuEvent(QContextMenuEvent *e) { +void GradientBarWidget::contextMenuEvent(QContextMenuEvent *e) +{ const int idx = hit_test(e->pos()); - if (idx < 0) - return; + if (idx < 0) return; // Only offer removal when at least 3 stops (keep minimum 2). - if (static_cast(stops_.size()) <= 2) - return; + if (static_cast(stops_.size()) <= 2) return; - QMenu menu(this); + QMenu menu(this); QAction *rm = menu.addAction(QObject::tr("Remove stop")); - if (menu.exec(e->globalPos()) == rm) { + if (menu.exec(e->globalPos()) == rm) + { stops_.erase(stops_.begin() + idx); if (selected_idx_ == idx) @@ -269,18 +301,21 @@ void GradientBarWidget::contextMenuEvent(QContextMenuEvent *e) { } } -QRectF GradientBarWidget::bar_rect() const { +QRectF GradientBarWidget::bar_rect() const +{ return QRectF(PAD, PAD, width() - 2 * PAD, BAR_H); } -QRectF GradientBarWidget::stop_rect(const Stop &s) const { +QRectF GradientBarWidget::stop_rect(const Stop &s) const +{ const QRectF br = bar_rect(); const double cx = br.left() + double(s.position) * br.width(); const double cy = br.bottom() + 3 + STOP_R; return QRectF(cx - STOP_R, cy - STOP_R, STOP_R * 2, STOP_R * 2); } -int GradientBarWidget::hit_test(const QPoint &pos) const { +int GradientBarWidget::hit_test(const QPoint &pos) const +{ for (int i = static_cast(stops_.size()) - 1; i >= 0; --i) if (stop_rect(stops_[i]).adjusted(-2, -2, 2, 2).contains(QPointF(pos))) return i; @@ -291,12 +326,18 @@ int GradientBarWidget::hit_test(const QPoint &pos) const { // PresetGridWidget: responsive grid that wraps swatches based on width // --------------------------------------------------------------------------- -class PresetGridWidget : public QWidget { +class PresetGridWidget : public QWidget +{ public: - explicit PresetGridWidget(int swatch_w, int swatch_h, int spacing = 4, + explicit PresetGridWidget(int swatch_w, + int swatch_h, + int spacing = 4, QWidget *parent = nullptr) - : QWidget(parent), swatch_w_(swatch_w), swatch_h_(swatch_h), - spacing_(spacing) { + : QWidget(parent), + swatch_w_(swatch_w), + swatch_h_(swatch_h), + spacing_(spacing) + { // Tell the layout system that our height depends on our width, so // QVBoxLayout / QScrollArea can query heightForWidth() instead of // relying on a stale, width-independent sizeHint(). @@ -307,18 +348,20 @@ class PresetGridWidget : public QWidget { init_layout(); } - void set_buttons(const std::vector &buttons) { + void set_buttons(const std::vector &buttons) + { buttons_ = buttons; current_cols_ = -1; reflow(width()); } - void reflow(int avail_w) { - if (buttons_.empty()) - return; + void reflow(int avail_w) + { + if (buttons_.empty()) return; const int cols = compute_cols(avail_w); - if (cols == current_cols_) { + if (cols == current_cols_) + { const int h = heightForWidth(avail_w); setMinimumHeight(h); return; @@ -331,7 +374,8 @@ class PresetGridWidget : public QWidget { init_layout(); for (size_t i = 0; i < buttons_.size(); ++i) - grid_->addWidget(buttons_[i], static_cast(i / cols), + grid_->addWidget(buttons_[i], + static_cast(i / cols), static_cast(i % cols)); const int h = heightForWidth(avail_w); @@ -341,43 +385,46 @@ class PresetGridWidget : public QWidget { bool hasHeightForWidth() const override { return true; } - int heightForWidth(int w) const override { - if (buttons_.empty()) - return 0; + int heightForWidth(int w) const override + { + if (buttons_.empty()) return 0; const int cols = compute_cols(w); const int rows = (static_cast(buttons_.size()) + cols - 1) / cols; return 4 + rows * swatch_h_ + (rows - 1) * spacing_; } - QSize sizeHint() const override { - if (buttons_.empty()) - return QSize(0, 0); + QSize sizeHint() const override + { + if (buttons_.empty()) return QSize(0, 0); const int cols = current_cols_ > 0 ? current_cols_ : 1; const int w = 4 + cols * swatch_w_ + (cols - 1) * spacing_; return QSize(w, heightForWidth(width() > 0 ? width() : w)); } - QSize minimumSizeHint() const override { - if (buttons_.empty()) - return QSize(0, 0); + QSize minimumSizeHint() const override + { + if (buttons_.empty()) return QSize(0, 0); return QSize(swatch_w_ + 4, heightForWidth(width() > 0 ? width() : swatch_w_ + 4)); } protected: - void resizeEvent(QResizeEvent *event) override { + void resizeEvent(QResizeEvent *event) override + { QWidget::resizeEvent(event); reflow(event->size().width()); } private: - int compute_cols(int avail_w) const { + int compute_cols(int avail_w) const + { const int margins_w = 4; return std::max(1, (avail_w - margins_w + spacing_) / (swatch_w_ + spacing_)); } - void init_layout() { + void init_layout() + { grid_ = new QGridLayout(this); grid_->setContentsMargins(2, 2, 2, 2); grid_->setSpacing(spacing_); @@ -385,11 +432,11 @@ class PresetGridWidget : public QWidget { grid_->setSizeConstraint(QLayout::SetNoConstraint); } - int swatch_w_; - int swatch_h_; - int spacing_; - int current_cols_ = -1; - QGridLayout *grid_ = nullptr; + int swatch_w_; + int swatch_h_; + int spacing_; + int current_cols_ = -1; + QGridLayout *grid_ = nullptr; std::vector buttons_; }; @@ -397,10 +444,11 @@ class PresetGridWidget : public QWidget { // Constructor // --------------------------------------------------------------------------- -GradientPicker::GradientPicker(std::vector &stops, +GradientPicker::GradientPicker(std::vector &stops, const std::vector &presets, - QWidget *parent) - : QWidget(parent), stops_(stops), presets_(presets) { + QWidget *parent) + : QWidget(parent), stops_(stops), presets_(presets) +{ ensure_gradient_library(); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); @@ -435,9 +483,13 @@ GradientPicker::GradientPicker(std::vector &stops, // the toolbar stays under the bar instead of floating mid-widget. main_layout->addStretch(0); - connect(bar_widget_, &GradientBarWidget::value_changed, this, + connect(bar_widget_, + &GradientBarWidget::value_changed, + this, &GradientPicker::value_changed); - connect(bar_widget_, &GradientBarWidget::edit_ended, this, + connect(bar_widget_, + &GradientBarWidget::edit_ended, + this, &GradientPicker::edit_ended); // Any picker (or the host) editing the library refreshes this grid @@ -451,13 +503,15 @@ GradientPicker::GradientPicker(std::vector &stops, // Toolbar // --------------------------------------------------------------------------- -QWidget *GradientPicker::build_toolbar() { +QWidget *GradientPicker::build_toolbar() +{ auto *bar = new QWidget(this); auto *layout = new QHBoxLayout(bar); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(4); - const auto make_button = [bar](const QString &text, const QString &tip) { + const auto make_button = [bar](const QString &text, const QString &tip) + { auto *button = new QToolButton(bar); button->setText(text); button->setToolTip(tip); @@ -470,16 +524,22 @@ QWidget *GradientPicker::build_toolbar() { save_button_ = make_button(tr("Save..."), tr("Save the current gradient to your library")); - import_button_ = - make_button(tr("Import..."), tr("Import gradients from JSON files")); - export_button_ = - make_button(tr("Export..."), tr("Export your library to a JSON file")); - - connect(save_button_, &QToolButton::clicked, this, + import_button_ = make_button(tr("Import..."), + tr("Import gradients from JSON files")); + export_button_ = make_button(tr("Export..."), + tr("Export your library to a JSON file")); + + connect(save_button_, + &QToolButton::clicked, + this, &GradientPicker::on_save_clicked); - connect(import_button_, &QToolButton::clicked, this, + connect(import_button_, + &QToolButton::clicked, + this, &GradientPicker::on_import_clicked); - connect(export_button_, &QToolButton::clicked, this, + connect(export_button_, + &QToolButton::clicked, + this, &GradientPicker::on_export_clicked); sort_combo_ = new QComboBox(bar); @@ -491,7 +551,9 @@ QWidget *GradientPicker::build_toolbar() { // `activated` fires for user picks only, so syncing the index from the // library in rebuild_preset_grid() cannot loop back. - connect(sort_combo_, QOverload::of(&QComboBox::activated), this, + connect(sort_combo_, + QOverload::of(&QComboBox::activated), + this, [](int index) { GradientLibrary::instance().set_sort( static_cast(index)); @@ -511,40 +573,44 @@ QWidget *GradientPicker::build_toolbar() { // Preset grid // --------------------------------------------------------------------------- -void GradientPicker::set_presets(const std::vector &presets) { +void GradientPicker::set_presets(const std::vector &presets) +{ presets_ = presets; rebuild_preset_grid(); } -void GradientPicker::update_bar() { - if (bar_widget_) - bar_widget_->update(); +void GradientPicker::update_bar() +{ + if (bar_widget_) bar_widget_->update(); } -void GradientPicker::schedule_rebuild() { +void GradientPicker::schedule_rebuild() +{ // Deferred: the notification may come from a slot of a swatch button that // the rebuild is about to delete, and bursts (imports) coalesce. - if (rebuild_pending_) - return; + if (rebuild_pending_) return; rebuild_pending_ = true; QMetaObject::invokeMethod( this, - [this]() { + [this]() + { rebuild_pending_ = false; rebuild_preset_grid(); }, Qt::QueuedConnection); } -void GradientPicker::rebuild_entries() { +void GradientPicker::rebuild_entries() +{ const GradientLibrary &lib = GradientLibrary::instance(); - const GradientSort sort = lib.sort(); + const GradientSort sort = lib.sort(); - struct Keyed { - Entry entry; - bool favorite; - float key; + struct Keyed + { + Entry entry; + bool favorite; + float key; std::string lower_name; std::size_t index; }; @@ -552,17 +618,23 @@ void GradientPicker::rebuild_entries() { std::vector keyed; keyed.reserve(presets_.size() + lib.presets().size()); - const auto push = [&](const Preset &preset, bool user) { - Keyed k{Entry{preset, user}, lib.is_favorite(preset.name), 0.f, preset.name, + const auto push = [&](const Preset &preset, bool user) + { + Keyed k{Entry{preset, user}, + lib.is_favorite(preset.name), + 0.f, + preset.name, keyed.size()}; - std::transform(k.lower_name.begin(), k.lower_name.end(), + std::transform(k.lower_name.begin(), + k.lower_name.end(), k.lower_name.begin(), [](unsigned char c) { return char(std::tolower(c)); }); if (sort == GradientSort::Luminance) k.key = gradient_luminance(preset.stops); - else if (sort == GradientSort::Hue) { + else if (sort == GradientSort::Hue) + { const float hue = gradient_hue(preset.stops); k.key = hue < 0.f ? 1e6f : hue; // achromatic last } @@ -575,22 +647,22 @@ void GradientPicker::rebuild_entries() { for (const auto &preset : lib.presets()) push(preset, true); - std::stable_sort(keyed.begin(), keyed.end(), - [sort](const Keyed &a, const Keyed &b) { - if (a.favorite != b.favorite) - return a.favorite; + std::stable_sort(keyed.begin(), + keyed.end(), + [sort](const Keyed &a, const Keyed &b) + { + if (a.favorite != b.favorite) return a.favorite; - switch (sort) { + switch (sort) + { case GradientSort::Name: return a.lower_name < b.lower_name; case GradientSort::Luminance: case GradientSort::Hue: - if (a.key != b.key) - return a.key < b.key; + if (a.key != b.key) return a.key < b.key; return a.lower_name < b.lower_name; case GradientSort::Default: - default: - return a.index < b.index; + default: return a.index < b.index; } }); @@ -600,8 +672,9 @@ void GradientPicker::rebuild_entries() { entries_.push_back(std::move(k.entry)); } -QPixmap GradientPicker::make_swatch(const Entry &entry, bool favorite) const { - QPixmap pix(SWATCH_W, SWATCH_H); +QPixmap GradientPicker::make_swatch(const Entry &entry, bool favorite) const +{ + QPixmap pix(SWATCH_W, SWATCH_H); QPainter pp(&pix); pp.setRenderHint(QPainter::Antialiasing); @@ -618,10 +691,10 @@ QPixmap GradientPicker::make_swatch(const Entry &entry, bool favorite) const { QString::fromStdString(entry.preset.name)); // Favourite star (top-left) and library marker (top-right) - if (favorite) - draw_star(pp, QPointF(8, 8), 5.5); + if (favorite) draw_star(pp, QPointF(8, 8), 5.5); - if (entry.user) { + if (entry.user) + { pp.setPen(QPen(QColor(40, 40, 40), 1)); pp.setBrush(Qt::white); pp.drawEllipse(QPointF(pix.width() - 7, 7), 3, 3); @@ -631,35 +704,39 @@ QPixmap GradientPicker::make_swatch(const Entry &entry, bool favorite) const { pp.setPen(QPen(QColor(80, 80, 80), 1)); pp.setBrush(Qt::NoBrush); pp.drawRoundedRect(pix.rect().adjusted(0, 0, -1, -1), - GradientBarWidget::RADIUS, GradientBarWidget::RADIUS); + GradientBarWidget::RADIUS, + GradientBarWidget::RADIUS); return pix; } -void GradientPicker::rebuild_preset_grid() { +void GradientPicker::rebuild_preset_grid() +{ const GradientLibrary &lib = GradientLibrary::instance(); rebuild_entries(); - if (sort_combo_) { + if (sort_combo_) + { const QSignalBlocker blocker(sort_combo_); sort_combo_->setCurrentIndex(static_cast(lib.sort())); } - if (export_button_) - export_button_->setEnabled(!lib.presets().empty()); + if (export_button_) export_button_->setEnabled(!lib.presets().empty()); scroll_area_->setVisible(!entries_.empty()); // Delete all existing child buttons inside preset_grid_ - qDeleteAll(preset_grid_->findChildren( - QString(), Qt::FindDirectChildrenOnly)); + qDeleteAll( + preset_grid_->findChildren(QString(), + Qt::FindDirectChildrenOnly)); std::vector buttons; buttons.reserve(entries_.size()); - for (std::size_t i = 0; i < entries_.size(); ++i) { - const Entry &entry = entries_[i]; - const bool favorite = lib.is_favorite(entry.preset.name); + for (std::size_t i = 0; i < entries_.size(); ++i) + { + const Entry &entry = entries_[i]; + const bool favorite = lib.is_favorite(entry.preset.name); const QString name = QString::fromStdString(entry.preset.name); auto *btn = new QPushButton(preset_grid_); @@ -678,11 +755,16 @@ void GradientPicker::rebuild_preset_grid() { btn->setContextMenuPolicy(Qt::CustomContextMenu); const std::vector preset_stops = entry.preset.stops; - connect(btn, &QPushButton::clicked, this, + connect(btn, + &QPushButton::clicked, + this, [this, preset_stops]() { apply_stops(preset_stops); }); - connect(btn, &QPushButton::customContextMenuRequested, this, - [this, btn, i](const QPoint &pos) { + connect(btn, + &QPushButton::customContextMenuRequested, + this, + [this, btn, i](const QPoint &pos) + { if (i < entries_.size()) show_entry_menu(entries_[i], btn->mapToGlobal(pos)); }); @@ -697,9 +779,11 @@ void GradientPicker::rebuild_preset_grid() { updateGeometry(); } -void GradientPicker::apply_stops(const std::vector &stops) { +void GradientPicker::apply_stops(const std::vector &stops) +{ stops_ = stops; - if (bar_widget_) { + if (bar_widget_) + { bar_widget_->sort_stops(); bar_widget_->update(); } @@ -707,7 +791,8 @@ void GradientPicker::apply_stops(const std::vector &stops) { Q_EMIT edit_ended(); } -std::vector GradientPicker::host_names() const { +std::vector GradientPicker::host_names() const +{ std::vector names; names.reserve(presets_.size()); for (const auto &preset : presets_) @@ -715,7 +800,8 @@ std::vector GradientPicker::host_names() const { return names; } -std::vector GradientPicker::entry_names() const { +std::vector GradientPicker::entry_names() const +{ std::vector names; names.reserve(entries_.size()); for (const auto &entry : entries_) @@ -727,7 +813,8 @@ std::vector GradientPicker::entry_names() const { // Library actions // --------------------------------------------------------------------------- -std::string GradientPicker::save_current_as_preset(const std::string &name) { +std::string GradientPicker::save_current_as_preset(const std::string &name) +{ GradientLibrary &lib = GradientLibrary::instance(); Preset preset; @@ -737,33 +824,41 @@ std::string GradientPicker::save_current_as_preset(const std::string &name) { return lib.add(std::move(preset)); } -void GradientPicker::on_save_clicked() { +void GradientPicker::on_save_clicked() +{ GradientLibrary &lib = GradientLibrary::instance(); - bool ok = false; + bool ok = false; const QString text = QInputDialog::getText( - this, tr("Save gradient"), tr("Preset name:"), QLineEdit::Normal, - QString::fromStdString(lib.unique_name("Gradient", host_names())), &ok); + this, + tr("Save gradient"), + tr("Preset name:"), + QLineEdit::Normal, + QString::fromStdString(lib.unique_name("Gradient", host_names())), + &ok); - if (!ok || text.trimmed().isEmpty()) - return; + if (!ok || text.trimmed().isEmpty()) return; save_current_as_preset(text.trimmed().toStdString()); } -void GradientPicker::on_import_clicked() { +void GradientPicker::on_import_clicked() +{ const QStringList files = QFileDialog::getOpenFileNames( - this, tr("Import gradients"), QString(), gradient_file_filter()); - if (files.isEmpty()) - return; + this, + tr("Import gradients"), + QString(), + gradient_file_filter()); + if (files.isEmpty()) return; GradientLibrary &lib = GradientLibrary::instance(); - QStringList failed; - std::size_t imported = 0; + QStringList failed; + std::size_t imported = 0; - for (const QString &file : files) { - const GradientImportReport report = - lib.import_file(std::filesystem::path(file.toStdString())); + for (const QString &file : files) + { + const GradientImportReport report = lib.import_file( + std::filesystem::path(file.toStdString())); if (!report.ok) failed << QFileInfo(file).fileName(); else @@ -772,49 +867,58 @@ void GradientPicker::on_import_clicked() { Logger::log()->trace("GradientPicker::on_import_clicked: {} imported from " "{} file(s), {} failed", - imported, files.size(), failed.size()); + imported, + files.size(), + failed.size()); if (!failed.isEmpty()) QMessageBox::warning( - this, tr("Import gradients"), + this, + tr("Import gradients"), tr("No gradients could be read from:\n%1").arg(failed.join('\n'))); } -void GradientPicker::on_export_clicked() { +void GradientPicker::on_export_clicked() +{ export_presets(GradientLibrary::instance().presets(), "gradients.json"); } void GradientPicker::export_presets(const std::vector &presets, - const QString &suggested_file) { - QString file = QFileDialog::getSaveFileName( - this, tr("Export gradients"), suggested_file, gradient_file_filter()); - if (file.isEmpty()) - return; - if (!file.endsWith(".json", Qt::CaseInsensitive)) - file += ".json"; + const QString &suggested_file) +{ + QString file = QFileDialog::getSaveFileName(this, + tr("Export gradients"), + suggested_file, + gradient_file_filter()); + if (file.isEmpty()) return; + if (!file.endsWith(".json", Qt::CaseInsensitive)) file += ".json"; if (!GradientLibrary::instance().export_file( - std::filesystem::path(file.toStdString()), presets)) - QMessageBox::warning(this, tr("Export gradients"), + std::filesystem::path(file.toStdString()), + presets)) + QMessageBox::warning(this, + tr("Export gradients"), tr("Could not write \"%1\".").arg(file)); } -void GradientPicker::show_entry_menu(Entry entry, const QPoint &global_pos) { +void GradientPicker::show_entry_menu(Entry entry, const QPoint &global_pos) +{ // `entry` is a copy on purpose: the menu runs a nested event loop during // which entries_ may be rebuilt. - GradientLibrary &lib = GradientLibrary::instance(); + GradientLibrary &lib = GradientLibrary::instance(); const std::string name = entry.preset.name; QMenu menu(this); - QAction *favorite = - menu.addAction(lib.is_favorite(name) ? tr("Remove from favorites") - : tr("Add to favorites")); + QAction *favorite = menu.addAction(lib.is_favorite(name) + ? tr("Remove from favorites") + : tr("Add to favorites")); QAction *rename = nullptr; QAction *replace = nullptr; QAction *remove = nullptr; - if (entry.user) { + if (entry.user) + { menu.addSeparator(); rename = menu.addAction(tr("Rename...")); replace = menu.addAction(tr("Replace with current gradient")); @@ -825,33 +929,44 @@ void GradientPicker::show_entry_menu(Entry entry, const QPoint &global_pos) { QAction *export_action = menu.addAction(tr("Export...")); QAction *chosen = menu.exec(global_pos); - if (!chosen) - return; + if (!chosen) return; - if (chosen == favorite) { + if (chosen == favorite) + { lib.set_favorite(name, !lib.is_favorite(name)); - } else if (chosen == rename) { - bool ok = false; - const QString text = QInputDialog::getText( - this, tr("Rename gradient"), tr("New name:"), QLineEdit::Normal, - QString::fromStdString(name), &ok); - if (!ok || text.trimmed().isEmpty()) - return; + } + else if (chosen == rename) + { + bool ok = false; + const QString text = QInputDialog::getText(this, + tr("Rename gradient"), + tr("New name:"), + QLineEdit::Normal, + QString::fromStdString(name), + &ok); + if (!ok || text.trimmed().isEmpty()) return; if (!lib.rename(name, text.trimmed().toStdString())) QMessageBox::warning( - this, tr("Rename gradient"), + this, + tr("Rename gradient"), tr("A gradient named \"%1\" already exists.").arg(text.trimmed())); - } else if (chosen == replace) { + } + else if (chosen == replace) + { lib.update(name, stops_); - } else if (chosen == remove) { - const auto answer = - QMessageBox::question(this, tr("Delete gradient"), - tr("Delete \"%1\" from your library?") - .arg(QString::fromStdString(name))); - if (answer == QMessageBox::Yes) - lib.remove(name); - } else if (chosen == export_action) { + } + else if (chosen == remove) + { + const auto answer = QMessageBox::question( + this, + tr("Delete gradient"), + tr("Delete \"%1\" from your library?") + .arg(QString::fromStdString(name))); + if (answer == QMessageBox::Yes) lib.remove(name); + } + else if (chosen == export_action) + { export_presets({entry.preset}, QString::fromStdString(name) + ".json"); } } @@ -860,30 +975,35 @@ void GradientPicker::show_entry_menu(Entry entry, const QPoint &global_pos) { // Geometry // --------------------------------------------------------------------------- -void GradientPicker::resizeEvent(QResizeEvent *e) { +void GradientPicker::resizeEvent(QResizeEvent *e) +{ QWidget::resizeEvent(e); - if (scroll_area_ && scroll_area_->viewport() && preset_grid_) { + if (scroll_area_ && scroll_area_->viewport() && preset_grid_) + { preset_grid_->reflow(scroll_area_->viewport()->width()); } } -bool GradientPicker::eventFilter(QObject *watched, QEvent *event) { +bool GradientPicker::eventFilter(QObject *watched, QEvent *event) +{ if (scroll_area_ && watched == scroll_area_->viewport() && - event->type() == QEvent::Resize) { + event->type() == QEvent::Resize) + { auto *re = static_cast(event); - if (preset_grid_) - preset_grid_->reflow(re->size().width()); + if (preset_grid_) preset_grid_->reflow(re->size().width()); } return QWidget::eventFilter(watched, event); } -QSize GradientPicker::sizeHint() const { +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; return {300, top_h + (entries_.empty() ? 0 : 4 + preset_h)}; } -QSize GradientPicker::minimumSizeHint() const { +QSize GradientPicker::minimumSizeHint() const +{ const int top_h = GradientBarWidget::TOTAL_H + 4 + 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/tests/test_qt/gradient_picker_snap/main.cpp b/tests/test_qt/gradient_picker_snap/main.cpp index 99cdaa2..ad530af 100644 --- a/tests/test_qt/gradient_picker_snap/main.cpp +++ b/tests/test_qt/gradient_picker_snap/main.cpp @@ -16,17 +16,20 @@ using meta::GradientSort; using meta::Preset; using meta::Stop; -static Preset make(const std::string &name, std::vector stops) { +static Preset make(const std::string &name, std::vector stops) +{ return {name, std::move(stops)}; } -static void snap(QWidget &w, const QString &path) { +static void snap(QWidget &w, const QString &path) +{ w.resize(320, 260); w.grab().save(path); } -int main(int argc, char **argv) { - QApplication app(argc, argv); +int main(int argc, char **argv) +{ + QApplication app(argc, argv); const QString dir = argc > 1 ? argv[1] : "."; QDir().mkpath(dir); @@ -41,21 +44,25 @@ int main(int argc, char **argv) { {{0.f, {0.78f, 0.86f, 1.f, 1.f}}, {1.f, {1.f, 1.f, 1.f, 1.f}}}), make("Sand", {{0.f, {0.76f, 0.7f, 0.5f, 1.f}}, {1.f, {0.94f, 0.9f, 0.7f, 1.f}}}), - make("Grass", {{0.f, {0.12f, 0.27f, 0.12f, 1.f}}, - {1.f, {0.5f, 0.75f, 0.3f, 1.f}}}), + make( + "Grass", + {{0.f, {0.12f, 0.27f, 0.12f, 1.f}}, {1.f, {0.5f, 0.75f, 0.3f, 1.f}}}), make("Ocean", {{0.f, {0.f, 0.05f, 0.25f, 1.f}}, {1.f, {0.2f, 0.6f, 0.9f, 1.f}}}), - make("Lava", {{0.f, {0.1f, 0.f, 0.f, 1.f}}, - {0.6f, {0.9f, 0.2f, 0.f, 1.f}}, - {1.f, {1.f, 0.9f, 0.3f, 1.f}}}), + make("Lava", + {{0.f, {0.1f, 0.f, 0.f, 1.f}}, + {0.6f, {0.9f, 0.2f, 0.f, 1.f}}, + {1.f, {1.f, 0.9f, 0.3f, 1.f}}}), make("Greys", {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {1.f, 1.f, 1.f, 1.f}}})}; - lib.add(make("My sunset", {{0.f, {0.2f, 0.f, 0.3f, 1.f}}, - {0.5f, {0.9f, 0.3f, 0.2f, 1.f}}, - {1.f, {1.f, 0.8f, 0.4f, 1.f}}})); - lib.add(make("Mint", {{0.f, {0.f, 0.3f, 0.25f, 1.f}}, - {1.f, {0.6f, 1.f, 0.85f, 1.f}}})); + lib.add(make("My sunset", + {{0.f, {0.2f, 0.f, 0.3f, 1.f}}, + {0.5f, {0.9f, 0.3f, 0.2f, 1.f}}, + {1.f, {1.f, 0.8f, 0.4f, 1.f}}})); + lib.add( + make("Mint", + {{0.f, {0.f, 0.3f, 0.25f, 1.f}}, {1.f, {0.6f, 1.f, 0.85f, 1.f}}})); lib.set_favorite("Ocean", true); lib.set_favorite("Mint", true); @@ -63,8 +70,11 @@ int main(int argc, char **argv) { {0.5f, {0.9f, 0.5f, 0.1f, 1.f}}, {1.f, {1.f, 1.f, 0.8f, 1.f}}}; - for (GradientSort sort : {GradientSort::Default, GradientSort::Name, - GradientSort::Luminance, GradientSort::Hue}) { + for (GradientSort sort : {GradientSort::Default, + GradientSort::Name, + GradientSort::Luminance, + GradientSort::Hue}) + { lib.set_sort(sort); meta::qt::GradientPicker picker(stops, host); snap(picker, diff --git a/tests/test_qt/test_combo_animation/main.cpp b/tests/test_qt/test_combo_animation/main.cpp index 95961eb..e24c46a 100644 --- a/tests/test_qt/test_combo_animation/main.cpp +++ b/tests/test_qt/test_combo_animation/main.cpp @@ -50,27 +50,35 @@ void key(ComboPopup *popup, int code) void mouse(ComboPopup *popup, QEvent::Type type, const QPoint &pos) { - QMouseEvent event(type, QPointF(pos), QPointF(popup->mapToGlobal(pos)), - Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QMouseEvent event(type, + QPointF(pos), + QPointF(popup->mapToGlobal(pos)), + Qt::LeftButton, + Qt::LeftButton, + Qt::NoModifier); QApplication::sendEvent(popup, &event); } void exercise(bool flipped, int dismissal, bool interrupt_open) { - meta::qt::Theme theme; - QWidget owner; + meta::qt::Theme theme; + QWidget owner; QPointer popup = new ComboPopup( - theme, {"add", "exclusion", "gradients", "maximum", "replace"}, 0, + theme, + {"add", "exclusion", "gradients", "maximum", "replace"}, + 0, &owner); popup->setAttribute(Qt::WA_DontShowOnScreen); int selections = 0; - QObject::connect(popup, &ComboPopup::selected, + QObject::connect(popup, + &ComboPopup::selected, [&selections](int) { ++selections; }); const QRect screen = QApplication::primaryScreen()->availableGeometry(); const QRect field(screen.left() + 100, - flipped ? screen.bottom() - 30 : screen.top() + 30, - 240, 24); + flipped ? screen.bottom() - 30 : screen.top() + 30, + 240, + 24); popup->popup_for(field); auto *animation = popup->findChild(); animation->pause(); @@ -92,14 +100,14 @@ void exercise(bool flipped, int dismissal, bool interrupt_open) animation->setCurrentTime(theme.metrics.section_ms / 5); const QImage opening = frame(popup); - const int partial = opaque_rows(opening); - check(partial > 0 && partial < opening.height(), "opening must reveal gradually"); + const int partial = opaque_rows(opening); + check(partial > 0 && partial < opening.height(), + "opening must reveal gradually"); const int hidden_y = flipped ? 0 : opening.height() - 1; check(qAlpha(opening.pixel(opening.width() / 2, hidden_y)) == 0, "unrevealed popup surface must be transparent"); - if (!interrupt_open) - animation->setCurrentTime(theme.metrics.section_ms); + if (!interrupt_open) animation->setCurrentTime(theme.metrics.section_ms); const int before_close = opaque_rows(frame(popup)); switch (dismissal) { @@ -107,11 +115,13 @@ void exercise(bool flipped, int dismissal, bool interrupt_open) case 1: mouse(popup, QEvent::MouseButtonPress, QPoint(-10, -10)); break; case 2: key(popup, Qt::Key_Return); break; case 3: - mouse(popup, QEvent::MouseButtonRelease, + mouse(popup, + QEvent::MouseButtonRelease, QPoint(popup->width() / 2, flipped ? popup->height() - 16 : 16)); break; } - check(popup && popup->isVisible(), "dismissal must keep popup visible while closing"); + check(popup && popup->isVisible(), + "dismissal must keep popup visible while closing"); if (!popup || !popup->isVisible()) return; check(opaque_rows(frame(popup)) == before_close, "closing must start at current reveal without jumping"); @@ -139,7 +149,8 @@ int main(int argc, char **argv) QApplication app(argc, argv); for (bool flipped : {false, true}) for (int dismissal = 0; dismissal < 4; ++dismissal) - for (bool interrupt : {false, true}) exercise(flipped, dismissal, interrupt); + for (bool interrupt : {false, true}) + exercise(flipped, dismissal, interrupt); std::cout << "16 popup scenarios; failures=" << failures << '\n'; return failures ? 1 : 0; } diff --git a/tests/test_qt/test_section_animation/main.cpp b/tests/test_qt/test_section_animation/main.cpp index 696a005..604e889 100644 --- a/tests/test_qt/test_section_animation/main.cpp +++ b/tests/test_qt/test_section_animation/main.cpp @@ -2,8 +2,8 @@ #include #include #include -#include #include +#include #include #include @@ -29,9 +29,9 @@ class PanelCheck : public QObject { public: std::array
sections{}; - int failures = 0; - int paints = 0; - bool watching = false; + int failures = 0; + int paints = 0; + bool watching = false; void check() { @@ -46,9 +46,8 @@ class PanelCheck : public QObject fail("section height differs from its requested reveal"); } const auto *layout = s->parentWidget()->layout(); - const int expected_y = i - ? sections[i - 1]->geometry().bottom() + 1 - + layout->spacing() + const int expected_y = i ? sections[i - 1]->geometry().bottom() + 1 + + layout->spacing() : layout->contentsMargins().top(); if (s->y() != expected_y) fail("section position or gap changed"); } @@ -73,7 +72,7 @@ class PanelCheck : public QObject int exercise(int viewport_height) { meta::qt::Theme theme; - QScrollArea scroll; + QScrollArea scroll; scroll.setAttribute(Qt::WA_DontShowOnScreen); scroll.setWidgetResizable(true); scroll.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); @@ -90,7 +89,7 @@ int exercise(int viewport_height) layout->setAlignment(Qt::AlignTop); node_layout->addWidget(page); - PanelCheck checker; + PanelCheck checker; const std::array rows{3, 3, 2, 8}; for (int i = 0; i < 4; ++i) { @@ -130,7 +129,7 @@ int exercise(int viewport_height) // must start at the displayed height, including repeated same-state calls. for (int i = 0; i < 4; ++i) { - auto *s = checker.sections[i]; + auto *s = checker.sections[i]; const bool initially_open = i != 3; s->set_expanded(!initially_open); advance(s, 48); @@ -166,8 +165,8 @@ int exercise(int viewport_height) int main(int argc, char **argv) { QApplication app(argc, argv); - int failures = 0; - for (int height : {1100, 740, 400}) failures += exercise(height); + int failures = 0; + for (int height : {1100, 740, 400}) + failures += exercise(height); return failures ? 1 : 0; } - diff --git a/tests/unittests/test_gradient_library.cpp b/tests/unittests/test_gradient_library.cpp index 1196900..f82c7d0 100644 --- a/tests/unittests/test_gradient_library.cpp +++ b/tests/unittests/test_gradient_library.cpp @@ -12,23 +12,27 @@ #include "meta/ext/color_gradient/gradient_library.hpp" -namespace { +namespace +{ -std::filesystem::path make_temp_dir(const std::string &tag) { - const auto dir = - std::filesystem::temp_directory_path() / ("meta_gradient_library_" + tag); +std::filesystem::path make_temp_dir(const std::string &tag) +{ + const auto dir = std::filesystem::temp_directory_path() / + ("meta_gradient_library_" + tag); std::filesystem::remove_all(dir); std::filesystem::create_directories(dir); return dir; } -meta::Preset make_preset(const std::string &name, float r, float g, float b) { +meta::Preset make_preset(const std::string &name, float r, float g, float b) +{ return {name, {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {r, g, b, 1.f}}}}; } } // namespace -TEST(GradientLibraryTest, AddMakesNamesUnique) { +TEST(GradientLibraryTest, AddMakesNamesUnique) +{ meta::GradientLibrary lib; EXPECT_EQ(lib.add(make_preset("Fire", 1.f, 0.f, 0.f)), "Fire"); @@ -45,7 +49,8 @@ TEST(GradientLibraryTest, AddMakesNamesUnique) { EXPECT_FALSE(lib.has("Water")); } -TEST(GradientLibraryTest, AddSortsStopsByPosition) { +TEST(GradientLibraryTest, AddSortsStopsByPosition) +{ meta::GradientLibrary lib; lib.add({"Rev", {{1.f, {1.f, 1.f, 1.f, 1.f}}, {0.f, {0.f, 0.f, 0.f, 1.f}}}}); @@ -56,7 +61,8 @@ TEST(GradientLibraryTest, AddSortsStopsByPosition) { EXPECT_EQ(lib.find("nope"), nullptr); } -TEST(GradientLibraryTest, UpdateRenameRemove) { +TEST(GradientLibraryTest, UpdateRenameRemove) +{ meta::GradientLibrary lib; lib.add(make_preset("A", 1.f, 0.f, 0.f)); lib.add(make_preset("B", 0.f, 1.f, 0.f)); @@ -83,7 +89,8 @@ TEST(GradientLibraryTest, UpdateRenameRemove) { EXPECT_TRUE(lib.presets().empty()); } -TEST(GradientLibraryTest, FavoritesFollowRenameAndRemoval) { +TEST(GradientLibraryTest, FavoritesFollowRenameAndRemoval) +{ meta::GradientLibrary lib; lib.add(make_preset("A", 1.f, 0.f, 0.f)); @@ -104,10 +111,11 @@ TEST(GradientLibraryTest, FavoritesFollowRenameAndRemoval) { EXPECT_TRUE(lib.favorites().empty()); } -TEST(GradientLibraryTest, ChangedFiresOncePerEffectiveMutation) { +TEST(GradientLibraryTest, ChangedFiresOncePerEffectiveMutation) +{ meta::GradientLibrary lib; - int count = 0; - auto conn = lib.changed.subscribe([&count]() { ++count; }); + int count = 0; + auto conn = lib.changed.subscribe([&count]() { ++count; }); lib.add(make_preset("A", 1.f, 0.f, 0.f)); EXPECT_EQ(count, 1); @@ -125,7 +133,8 @@ TEST(GradientLibraryTest, ChangedFiresOncePerEffectiveMutation) { EXPECT_EQ(count, 4); } -TEST(GradientLibraryTest, JsonRoundTrip) { +TEST(GradientLibraryTest, JsonRoundTrip) +{ meta::GradientLibrary lib; lib.add(make_preset("A", 1.f, 0.f, 0.f)); lib.add(make_preset("B", 0.f, 1.f, 0.f)); @@ -155,7 +164,8 @@ TEST(GradientLibraryTest, JsonRoundTrip) { EXPECT_TRUE(empty.presets().empty()); } -TEST(GradientLibraryTest, AutosaveAndLoad) { +TEST(GradientLibraryTest, AutosaveAndLoad) +{ const auto dir = make_temp_dir("autosave"); const auto file = dir / "nested" / "gradients.json"; @@ -194,7 +204,8 @@ TEST(GradientLibraryTest, AutosaveAndLoad) { EXPECT_TRUE(std::filesystem::exists(dir / "manual.json")); } -TEST(GradientLibraryTest, CorruptFileLeavesStateUntouched) { +TEST(GradientLibraryTest, CorruptFileLeavesStateUntouched) +{ const auto dir = make_temp_dir("corrupt"); meta::GradientLibrary lib; @@ -217,7 +228,8 @@ TEST(GradientLibraryTest, CorruptFileLeavesStateUntouched) { EXPECT_EQ(lib.presets().size(), 1u); } -TEST(GradientLibraryTest, ImportPolicy) { +TEST(GradientLibraryTest, ImportPolicy) +{ const auto dir = make_temp_dir("import"); meta::GradientLibrary source; @@ -251,7 +263,8 @@ TEST(GradientLibraryTest, ImportPolicy) { EXPECT_EQ(lib.presets().size(), 4u); } -TEST(GradientLibraryTest, ParsesHesiodFileShapes) { +TEST(GradientLibraryTest, ParsesHesiodFileShapes) +{ // Hesiod data/color_gradients/.json: Meta's ColorGradient::json_to // shape, no name -> the fallback (file stem) is used const auto per_file = nlohmann::json::parse(R"({ @@ -260,7 +273,7 @@ TEST(GradientLibraryTest, ParsesHesiodFileShapes) { {"color": [0.1, 0.2, 0.3, 1.0], "position": 0.0}, {"color": [0.5, 0.6, 0.7, 1.0], "position": 1.0} ]})"); - auto parsed = meta::parse_gradient_file(per_file, "051c4a"); + auto parsed = meta::parse_gradient_file(per_file, "051c4a"); ASSERT_TRUE(parsed.has_value()); ASSERT_EQ(parsed->size(), 1u); EXPECT_EQ((*parsed)[0].name, "051c4a"); @@ -305,10 +318,14 @@ TEST(GradientLibraryTest, ParsesHesiodFileShapes) { EXPECT_FALSE(meta::parse_gradient_file(nlohmann::json::object()).has_value()); } -TEST(GradientLibraryTest, SortNamesRoundTrip) { +TEST(GradientLibraryTest, SortNamesRoundTrip) +{ using meta::GradientSort; - for (GradientSort s : {GradientSort::Default, GradientSort::Name, - GradientSort::Luminance, GradientSort::Hue}) { + for (GradientSort s : {GradientSort::Default, + GradientSort::Name, + GradientSort::Luminance, + GradientSort::Hue}) + { const auto back = meta::gradient_sort_from_string(meta::to_string(s)); ASSERT_TRUE(back.has_value()); EXPECT_EQ(*back, s); diff --git a/tests/unittests/test_gradient_metrics.cpp b/tests/unittests/test_gradient_metrics.cpp index 9b2d4d6..d8d3e16 100644 --- a/tests/unittests/test_gradient_metrics.cpp +++ b/tests/unittests/test_gradient_metrics.cpp @@ -9,18 +9,21 @@ #include "meta/ext/color_gradient/gradient_metrics.hpp" -namespace { +namespace +{ -meta::Stop stop(float t, float r, float g, float b, float a = 1.f) { +meta::Stop stop(float t, float r, float g, float b, float a = 1.f) +{ return {t, {r, g, b, a}}; } } // namespace -TEST(GradientMetricsTest, StopAndPresetEquality) { +TEST(GradientMetricsTest, StopAndPresetEquality) +{ const meta::Preset a{"x", {stop(0.f, 0.f, 0.f, 0.f), stop(1.f, 1.f, 1.f, 1.f)}}; - meta::Preset b = a; + meta::Preset b = a; EXPECT_EQ(a, b); b.stops[1].color[0] = 0.5f; EXPECT_NE(a, b); @@ -29,7 +32,8 @@ TEST(GradientMetricsTest, StopAndPresetEquality) { EXPECT_NE(a, b); } -TEST(GradientMetricsTest, SampleInterpolatesAndClamps) { +TEST(GradientMetricsTest, SampleInterpolatesAndClamps) +{ // deliberately unsorted const std::vector stops = {stop(1.f, 1.f, 1.f, 1.f, 1.f), stop(0.f, 0.f, 0.f, 0.f, 0.f)}; @@ -46,7 +50,8 @@ TEST(GradientMetricsTest, SampleInterpolatesAndClamps) { EXPECT_FLOAT_EQ(empty[3], 1.f); } -TEST(GradientMetricsTest, SampleHoldsEndColorsOutsideStopRange) { +TEST(GradientMetricsTest, SampleHoldsEndColorsOutsideStopRange) +{ const std::vector stops = {stop(0.25f, 1.f, 0.f, 0.f), stop(0.75f, 0.f, 0.f, 1.f)}; EXPECT_FLOAT_EQ(meta::sample_gradient(stops, 0.1f)[0], 1.f); @@ -54,7 +59,8 @@ TEST(GradientMetricsTest, SampleHoldsEndColorsOutsideStopRange) { EXPECT_NEAR(meta::sample_gradient(stops, 0.5f)[0], 0.5f, 1e-5f); } -TEST(GradientMetricsTest, LuminanceOrdersDarkToLight) { +TEST(GradientMetricsTest, LuminanceOrdersDarkToLight) +{ const std::vector black = {stop(0.f, 0.f, 0.f, 0.f), stop(1.f, 0.f, 0.f, 0.f)}; const std::vector grey = {stop(0.f, 0.5f, 0.5f, 0.5f), @@ -71,7 +77,8 @@ TEST(GradientMetricsTest, LuminanceOrdersDarkToLight) { EXPECT_FLOAT_EQ(meta::gradient_luminance({}), 0.f); } -TEST(GradientMetricsTest, HueOfPrimaries) { +TEST(GradientMetricsTest, HueOfPrimaries) +{ const std::vector red = {stop(0.f, 1.f, 0.f, 0.f), stop(1.f, 1.f, 0.f, 0.f)}; const std::vector green = {stop(0.f, 0.f, 1.f, 0.f), @@ -85,15 +92,17 @@ TEST(GradientMetricsTest, HueOfPrimaries) { EXPECT_NEAR(meta::gradient_hue(blue), 240.f, 0.5f); } -TEST(GradientMetricsTest, HueAveragesAcrossWrapAround) { +TEST(GradientMetricsTest, HueAveragesAcrossWrapAround) +{ // 350 deg -> 10 deg through red: the circular mean sits near 0, not 180 const std::vector stops = {stop(0.f, 1.f, 0.f, 1.f / 6.f), stop(1.f, 1.f, 1.f / 6.f, 0.f)}; - const float h = meta::gradient_hue(stops); + const float h = meta::gradient_hue(stops); EXPECT_TRUE(h < 5.f || h > 355.f) << h; } -TEST(GradientMetricsTest, AchromaticGradientHasNoHue) { +TEST(GradientMetricsTest, AchromaticGradientHasNoHue) +{ const std::vector ramp = {stop(0.f, 0.f, 0.f, 0.f), stop(1.f, 1.f, 1.f, 1.f)}; EXPECT_FLOAT_EQ(meta::gradient_hue(ramp), -1.f); diff --git a/tests/unittests/test_gradient_picker.cpp b/tests/unittests/test_gradient_picker.cpp index a1c02ef..141199a 100644 --- a/tests/unittests/test_gradient_picker.cpp +++ b/tests/unittests/test_gradient_picker.cpp @@ -15,24 +15,29 @@ #include "meta/ext/color_gradient/gradient_library.hpp" #include "meta_qt/widgets/gradient_picker.hpp" -namespace { +namespace +{ -std::vector host_presets() { +std::vector host_presets() +{ return { {"Host B", {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {1.f, 0.f, 0.f, 1.f}}}}, {"Host A", {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {0.f, 0.f, 1.f, 1.f}}}}}; } -meta::Preset lib_preset(const std::string &name) { +meta::Preset lib_preset(const std::string &name) +{ return {name, {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {0.f, 1.f, 0.f, 1.f}}}}; } // Points the process-wide library at a scratch file and empties it, so the // tests never touch a real per-user library. -struct IsolatedLibrary { - IsolatedLibrary() { - const auto dir = - std::filesystem::temp_directory_path() / "meta_gradient_picker_test"; +struct IsolatedLibrary +{ + IsolatedLibrary() + { + const auto dir = std::filesystem::temp_directory_path() / + "meta_gradient_picker_test"; std::filesystem::remove_all(dir); auto &lib = meta::GradientLibrary::instance(); @@ -41,7 +46,8 @@ struct IsolatedLibrary { lib.set_sort(meta::GradientSort::Default); } - ~IsolatedLibrary() { + ~IsolatedLibrary() + { auto &lib = meta::GradientLibrary::instance(); lib.clear(); lib.set_sort(meta::GradientSort::Default); @@ -49,23 +55,26 @@ struct IsolatedLibrary { }; // Library notifications rebuild the grid through a queued call. -void flush() { +void flush() +{ QCoreApplication::sendPostedEvents(); QCoreApplication::processEvents(); } -int swatch_count(const meta::qt::GradientPicker &picker) { +int swatch_count(const meta::qt::GradientPicker &picker) +{ return static_cast(picker.findChildren().size()); } } // namespace -TEST(GradientPickerTest, MergesHostAndLibraryPresets) { +TEST(GradientPickerTest, MergesHostAndLibraryPresets) +{ IsolatedLibrary isolated; meta::GradientLibrary::instance().add(lib_preset("Lib")); - std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, - {1.f, {1.f, 1.f, 1.f, 1.f}}}; + std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, + {1.f, {1.f, 1.f, 1.f, 1.f}}}; meta::qt::GradientPicker picker(stops, host_presets()); EXPECT_EQ(picker.entry_names(), @@ -74,18 +83,18 @@ TEST(GradientPickerTest, MergesHostAndLibraryPresets) { int user_count = 0; for (auto *button : picker.findChildren()) - if (button->property("preset_user").toBool()) - ++user_count; + if (button->property("preset_user").toBool()) ++user_count; EXPECT_EQ(user_count, 1); } -TEST(GradientPickerTest, FavoritesPinnedFirstThenSortKey) { +TEST(GradientPickerTest, FavoritesPinnedFirstThenSortKey) +{ IsolatedLibrary isolated; - auto &lib = meta::GradientLibrary::instance(); + auto &lib = meta::GradientLibrary::instance(); lib.add(lib_preset("Lib")); - std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, - {1.f, {1.f, 1.f, 1.f, 1.f}}}; + std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, + {1.f, {1.f, 1.f, 1.f, 1.f}}}; meta::qt::GradientPicker picker(stops, host_presets()); lib.set_favorite("Lib", true); @@ -104,28 +113,30 @@ TEST(GradientPickerTest, FavoritesPinnedFirstThenSortKey) { (std::vector{"Host A", "Host B", "Lib"})); } -TEST(GradientPickerTest, LuminanceSortGoesDarkToLight) { +TEST(GradientPickerTest, LuminanceSortGoesDarkToLight) +{ IsolatedLibrary isolated; - auto &lib = meta::GradientLibrary::instance(); + auto &lib = meta::GradientLibrary::instance(); lib.add( {"Bright", {{0.f, {1.f, 1.f, 1.f, 1.f}}, {1.f, {1.f, 1.f, 1.f, 1.f}}}}); lib.add( {"Dark", {{0.f, {0.f, 0.f, 0.f, 1.f}}, {1.f, {0.1f, 0.1f, 0.1f, 1.f}}}}); lib.set_sort(meta::GradientSort::Luminance); - std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, - {1.f, {1.f, 1.f, 1.f, 1.f}}}; + std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, + {1.f, {1.f, 1.f, 1.f, 1.f}}}; meta::qt::GradientPicker picker(stops, {}); EXPECT_EQ(picker.entry_names(), (std::vector{"Dark", "Bright"})); } -TEST(GradientPickerTest, SaveCurrentAsPresetAvoidsHostNames) { +TEST(GradientPickerTest, SaveCurrentAsPresetAvoidsHostNames) +{ IsolatedLibrary isolated; - auto &lib = meta::GradientLibrary::instance(); + auto &lib = meta::GradientLibrary::instance(); - std::vector stops = {{1.f, {1.f, 1.f, 0.f, 1.f}}, - {0.f, {0.f, 0.f, 0.f, 1.f}}}; + std::vector stops = {{1.f, {1.f, 1.f, 0.f, 1.f}}, + {0.f, {0.f, 0.f, 0.f, 1.f}}}; meta::qt::GradientPicker picker(stops, host_presets()); EXPECT_EQ(picker.save_current_as_preset("Mine"), "Mine"); @@ -143,12 +154,13 @@ TEST(GradientPickerTest, SaveCurrentAsPresetAvoidsHostNames) { EXPECT_EQ(swatch_count(picker), 5); } -TEST(GradientPickerTest, LibraryChangesRebuildTheGrid) { +TEST(GradientPickerTest, LibraryChangesRebuildTheGrid) +{ IsolatedLibrary isolated; - auto &lib = meta::GradientLibrary::instance(); + auto &lib = meta::GradientLibrary::instance(); - std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, - {1.f, {1.f, 1.f, 1.f, 1.f}}}; + std::vector stops = {{0.f, {0.f, 0.f, 0.f, 1.f}}, + {1.f, {1.f, 1.f, 1.f, 1.f}}}; meta::qt::GradientPicker picker(stops, host_presets()); EXPECT_EQ(swatch_count(picker), 2);