diff --git a/include/ColorHelper.h b/include/ColorHelper.h new file mode 100644 index 000000000..78f99b9e2 --- /dev/null +++ b/include/ColorHelper.h @@ -0,0 +1,54 @@ +/* ColorHelper.h - Helper methods for color related algorithms, etc. + * + * Copyright (c) 2024- Michael Gregorius + * + * This file is part of LMMS - https://lmms.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program (see COPYING); if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA. + * + */ + +#ifndef LMMS_GUI_COLOR_HELPER_H +#define LMMS_GUI_COLOR_HELPER_H + +#include + +namespace lmms::gui +{ + +class ColorHelper +{ +public: + static QColor interpolateInRgb(const QColor& a, const QColor& b, float t) + { + qreal ar, ag, ab, aa; + a.getRgbF(&ar, &ag, &ab, &aa); + + qreal br, bg, bb, ba; + b.getRgbF(&br, &bg, &bb, &ba); + + const float interH = lerp(ar, br, t); + const float interS = lerp(ag, bg, t); + const float interV = lerp(ab, bb, t); + const float interA = lerp(aa, ba, t); + + return QColor::fromRgbF(interH, interS, interV, interA); + } +}; + +} // namespace lmms::gui + +#endif // LMMS_GUI_COLOR_HELPER_H diff --git a/include/EnvelopeGraph.h b/include/EnvelopeGraph.h index 8cfeaf11f..4f8a5c386 100644 --- a/include/EnvelopeGraph.h +++ b/include/EnvelopeGraph.h @@ -41,14 +41,23 @@ namespace gui class EnvelopeGraph : public QWidget, public ModelView { +public: + enum class ScalingMode + { + Dynamic, + Absolute, + Relative + }; + public: EnvelopeGraph(QWidget* parent); protected: void modelChanged() override; - void mousePressEvent(QMouseEvent* me) override; - void paintEvent(QPaintEvent* pe) override; + void mousePressEvent(QMouseEvent*) override; + void contextMenuEvent(QContextMenuEvent*) override; + void paintEvent(QPaintEvent*) override; private: void toggleAmountModel(); @@ -56,7 +65,9 @@ private: private: QPixmap m_envGraph = embed::getIconPixmap("envelope_graph"); - EnvelopeAndLfoParameters* m_params; + EnvelopeAndLfoParameters* m_params = nullptr; + + ScalingMode m_scaling = ScalingMode::Dynamic; }; } // namespace gui diff --git a/include/lmms_math.h b/include/lmms_math.h index 3f58e3b75..bf5e53a2b 100644 --- a/include/lmms_math.h +++ b/include/lmms_math.h @@ -327,6 +327,13 @@ static inline T absMin( T a, T b ) return std::abs(a) < std::abs(b) ? a : b; } +//! Returns the linear interpolation of the two values +template +constexpr T lerp(T a, T b, F t) +{ + return (1. - t) * a + t * b; +} + // @brief Calculate number of digits which LcdSpinBox would show for a given number // @note Once we upgrade to C++20, we could probably use std::formatted_size static inline int numDigitsAsInt(float f) diff --git a/src/gui/instrument/EnvelopeGraph.cpp b/src/gui/instrument/EnvelopeGraph.cpp index 4d866b3cc..4cf5da74b 100644 --- a/src/gui/instrument/EnvelopeGraph.cpp +++ b/src/gui/instrument/EnvelopeGraph.cpp @@ -23,12 +23,18 @@ * */ -#include -#include - #include "EnvelopeGraph.h" +#include +#include +#include + #include "EnvelopeAndLfoParameters.h" +#include "lmms_math.h" +#include "ColorHelper.h" + +#include + namespace lmms { @@ -36,15 +42,11 @@ namespace lmms namespace gui { -const int TIME_UNIT_WIDTH = 40; - - EnvelopeGraph::EnvelopeGraph(QWidget* parent) : QWidget(parent), - ModelView(nullptr, this), - m_params(nullptr) + ModelView(nullptr, this) { - setFixedSize(m_envGraph.size()); + setMinimumSize(m_envGraph.size()); } void EnvelopeGraph::modelChanged() @@ -54,87 +56,196 @@ void EnvelopeGraph::modelChanged() void EnvelopeGraph::mousePressEvent(QMouseEvent* me) { - if(me->button() == Qt::LeftButton) + if (me->button() == Qt::LeftButton) { toggleAmountModel(); } +} + +void EnvelopeGraph::contextMenuEvent(QContextMenuEvent* event) +{ + QMenu menu(this); + QMenu* scalingMenu = menu.addMenu(tr("Scaling")); + scalingMenu->setToolTipsVisible(true); + + auto switchTo = [&](ScalingMode scaling) { - toggleAmountModel(); - } + if (m_scaling != scaling) + { + m_scaling = scaling; + update(); + } + }; + + auto addScalingEntry = [scalingMenu, &switchTo, this](const QString& text, const QString& toolTip, ScalingMode scaling) + { + QAction* action = scalingMenu->addAction(text, [&switchTo, scaling]() { switchTo(scaling); }); + action->setCheckable(true); + action->setChecked(m_scaling == scaling); + action->setToolTip(toolTip); + }; + + addScalingEntry( + tr("Dynamic"), + tr("Uses absolute spacings but switches to relative spacing if it's running out of space"), + ScalingMode::Dynamic); + addScalingEntry( + tr("Absolute"), + tr("Provides enough potential space for each segment but does not scale"), + ScalingMode::Absolute); + addScalingEntry( + tr("Relative"), + tr("Always uses all of the available space to display the envelope graph"), + ScalingMode::Relative); + + menu.exec(event->globalPos()); } void EnvelopeGraph::paintEvent(QPaintEvent*) { - QPainter p(this); + QPainter p{this}; p.setRenderHint(QPainter::Antialiasing); // Draw the graph background p.drawPixmap(rect(), m_envGraph); - const auto * params = castModel(); - if (!params) - { - return; - } + const auto* params = castModel(); + if (!params) { return; } + // For the calculation of the percentages we will for now make use of the knowledge + // that the range goes from 0 to a positive max value, i.e. that it is in [0, max]. const float amount = params->getAmountModel().value(); + const float predelay = params->getPredelayModel().value(); + const float predelayPercentage = predelay / params->getPredelayModel().maxValue(); + const float attack = params->getAttackModel().value(); + const float attackPercentage = attack / params->getAttackModel().maxValue(); + const float hold = params->getHoldModel().value(); + const float holdPercentage = hold / params->getHoldModel().maxValue(); + const float decay = params->getDecayModel().value(); + const float decayPercentage = decay / params->getDecayModel().maxValue(); + const float sustain = params->getSustainModel().value(); + const float release = params->getReleaseModel().value(); + const float releasePercentage = release / params->getReleaseModel().maxValue(); - const float gray_amount = 1.0f - fabsf(amount); + // The margin to the left and right so that we do not clip too much of the lines and markers + const float margin = 2.0; + const float availableWidth = width() - margin * 2; - p.setPen(QPen(QColor(static_cast(96 * gray_amount), - static_cast(255 - 159 * gray_amount), - static_cast(128 - 32 * gray_amount)), - 2)); - - const QColor end_points_color(0x99, 0xAF, 0xFF); - const QColor end_points_bg_color(0, 0, 2); - - const int y_base = m_envGraph.height() - 3; - const int avail_height = m_envGraph.height() - 6; - - int x1 = static_cast(predelay * TIME_UNIT_WIDTH); - int x2 = x1 + static_cast(attack * TIME_UNIT_WIDTH); - int x3 = x2 + static_cast(hold * TIME_UNIT_WIDTH); - int x4 = x3 + static_cast((decay * (1 - sustain)) * TIME_UNIT_WIDTH); - int x5 = x4 + static_cast(release * TIME_UNIT_WIDTH); - - if (x5 > 174) + // Now determine the maximum width for one segment according to the scaling setting. + // The different scalings use different means to compute the maximum available width per segment. + const auto computeMaximumSegmentWidthAbsolute = [&]() -> float { - x1 = (x1 * 174) / x5; - x2 = (x2 * 174) / x5; - x3 = (x3 * 174) / x5; - x4 = (x4 * 174) / x5; - x5 = (x5 * 174) / x5; + return availableWidth / 5.; + }; + + const auto computeMaximumSegmentWidthRelative = [&]() -> float + { + const float sumOfSegments = predelayPercentage + attackPercentage + holdPercentage + decayPercentage + releasePercentage; + + return sumOfSegments != 0. + ? availableWidth / sumOfSegments + : computeMaximumSegmentWidthAbsolute(); + }; + + const auto computeMaximumSegmentWidthDynamic = [&]() -> float + { + const float sumOfSegments = predelayPercentage + attackPercentage + holdPercentage + decayPercentage + releasePercentage; + + float preliminarySegmentWidth = 80. / 182. * availableWidth; + + const float neededWidth = sumOfSegments * preliminarySegmentWidth; + + if (neededWidth > availableWidth && sumOfSegments != 0.) + { + return computeMaximumSegmentWidthRelative(); + } + + return preliminarySegmentWidth; + }; + + // This is the maximum width that each of the five segments (DAHDR) can occupy. + float maximumSegmentWidth; + + switch (m_scaling) + { + case ScalingMode::Absolute: + maximumSegmentWidth = computeMaximumSegmentWidthAbsolute(); + break; + case ScalingMode::Relative: + maximumSegmentWidth = computeMaximumSegmentWidthRelative(); + break; + case ScalingMode::Dynamic: + default: + maximumSegmentWidth = computeMaximumSegmentWidthDynamic(); + break; } - x1 += 2; - x2 += 2; - x3 += 2; - x4 += 2; - x5 += 2; - p.drawLine(x1, y_base, x2, y_base - avail_height); - p.fillRect(x1 - 1, y_base - 2, 4, 4, end_points_bg_color); - p.fillRect(x1, y_base - 1, 2, 2, end_points_color); + // Compute the actual widths that the segments occupy and add them to the + // previous x coordinates starting at the margin. + const float predelayX = margin + predelayPercentage * maximumSegmentWidth; + const float attackX = predelayX + attackPercentage * maximumSegmentWidth; + const float holdX = attackX + holdPercentage * maximumSegmentWidth; + const float decayX = holdX + (decayPercentage * (1 - sustain)) * maximumSegmentWidth; + const float releaseX = decayX + releasePercentage * maximumSegmentWidth; - p.drawLine(x2, y_base - avail_height, x3, y_base - avail_height); - p.fillRect(x2 - 1, y_base - 2 - avail_height, 4, 4, - end_points_bg_color); - p.fillRect(x2, y_base - 1 - avail_height, 2, 2, end_points_color); + // Now compute the "full" points including y coordinates + const int yTop = 3; + const qreal yBase = height() - 3; + const int availableHeight = yBase - yTop; - const int sustainHeight = static_cast(y_base - avail_height + (1 - sustain) * avail_height); + const QPointF predelayPoint{predelayX, yBase}; + const QPointF attackPoint{attackX, yTop}; + const QPointF holdPoint{holdX, yTop}; + const QPointF decayPoint{decayX, yTop + (1 - sustain) * availableHeight}; + const QPointF releasePoint{releaseX, yBase}; - p.drawLine(x3, y_base-avail_height, x4, sustainHeight); - p.fillRect(x3 - 1, y_base - 2 - avail_height, 4, 4, end_points_bg_color); - p.fillRect(x3, y_base - 1 - avail_height, 2, 2, end_points_color); - - p.drawLine(x4, sustainHeight, x5, y_base); - p.fillRect(x4 - 1, sustainHeight - 2, 4, 4, end_points_bg_color); - p.fillRect(x4, sustainHeight - 1, 2, 2, end_points_color); - p.fillRect(x5 - 1, y_base - 2, 4, 4, end_points_bg_color); - p.fillRect(x5, y_base - 1, 2, 2, end_points_color); + // Now that we have all points we can draw the lines + + // Compute the color of the lines based on the amount of the envelope + const float absAmount = std::abs(amount); + const QColor noAmountColor{96, 91, 96}; + const QColor fullAmountColor{0, 255, 128}; + const QColor lineColor{ColorHelper::interpolateInRgb(noAmountColor, fullAmountColor, absAmount)}; + + // Determine the line width so that it scales with the widget + // Use the minimum value of the current width and height to compute it. + const qreal lineWidth = std::min(width(), height()) / 20.; + const QPen linePen{lineColor, lineWidth}; + p.setPen(linePen); + + QPolygonF linePoly; + linePoly << predelayPoint << attackPoint << holdPoint << decayPoint << releasePoint; + p.drawPolyline(linePoly); + + // Now draw all marker on top of the lines + const QColor markerFillColor{153, 175, 255}; + const QColor markerOutlineColor{0, 0, 0}; + + QPen pen; + pen.setWidthF(lineWidth * 0.75); + pen.setBrush(markerOutlineColor); + p.setPen(pen); + p.setBrush(markerFillColor); + + // Compute the size of the circle we will draw based on the line width + const qreal baseRectSize = lineWidth * 3; + const QSizeF rectSize{baseRectSize, baseRectSize}; + + auto drawMarker = [&](const QPointF& point) + { + // Create a rectangle that has the given point at its center + QRectF bgRect{point + QPointF(-baseRectSize / 2, -baseRectSize / 2), rectSize}; + p.drawEllipse(bgRect); + }; + + drawMarker(predelayPoint); + drawMarker(attackPoint); + drawMarker(holdPoint); + drawMarker(decayPoint); + drawMarker(releasePoint); } void EnvelopeGraph::toggleAmountModel() @@ -142,14 +253,7 @@ void EnvelopeGraph::toggleAmountModel() auto* params = castModel(); auto& amountModel = params->getAmountModel(); - if (amountModel.value() < 1.0f ) - { - amountModel.setValue( 1.0f ); - } - else - { - amountModel.setValue( 0.0f ); - } + amountModel.setValue(amountModel.value() < 1.0 ? 1.0 : 0.0); } } // namespace gui