LCOV - code coverage report
Current view: top level - src/notationscene/qml/MuseScore/NotationScene - notationautomationcontroller.cpp Coverage Total Hit
Test: filtered_coverage.info Lines: 0.0 % 692 0
Test Date: 2026-11-05 04:07:02

            Line data    Source code
       1              : /*
       2              :  * SPDX-License-Identifier: GPL-3.0-only
       3              :  * MuseScore-Studio-CLA-applies
       4              :  *
       5              :  * MuseScore Studio
       6              :  * Music Composition & Notation
       7              :  *
       8              :  * Copyright (C) 2026 MuseScore Limited and others
       9              :  *
      10              :  * This program is free software: you can redistribute it and/or modify
      11              :  * it under the terms of the GNU General Public License version 3 as
      12              :  * published by the Free Software Foundation.
      13              :  *
      14              :  * This program is distributed in the hope that it will be useful,
      15              :  * but WITHOUT ANY WARRANTY; without even the implied warranty of
      16              :  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
      17              :  * GNU General Public License for more details.
      18              :  *
      19              :  * You should have received a copy of the GNU General Public License
      20              :  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
      21              :  */
      22              : 
      23              : #include "notationautomationcontroller.h"
      24              : 
      25              : #include <algorithm>
      26              : #include <cmath>
      27              : #include <optional>
      28              : #include <set>
      29              : 
      30              : #include "uicomponents/qml/Muse/UiComponents/polylineplot.h"
      31              : 
      32              : #include "engraving/iengravingconfiguration.h" // IWYU pragma: keep
      33              : #include "engraving/automation/automationdata.h"
      34              : #include "engraving/automation/dynamicvalues.h"
      35              : #include "engraving/automation/tempovalues.h"
      36              : #include "engraving/dom/masterscore.h"
      37              : #include "engraving/dom/part.h"
      38              : #include "engraving/dom/staff.h"
      39              : 
      40              : #include "notation/imasternotation.h"
      41              : #include "notation/inotation.h"
      42              : #include "notation/inotationautomation.h"
      43              : #include "notation/inotationelements.h" // IWYU pragma: keep
      44              : 
      45              : #include "global/async/async.h"
      46              : #include "global/containers.h"
      47              : #include "global/realfn.h"
      48              : 
      49              : using namespace mu::notation;
      50              : using namespace mu::engraving;
      51              : using namespace muse::uicomponents;
      52              : 
      53              : using SetPoint = mu::engraving::AutomationPointEdit::SetPoint;
      54              : using MovePoint = mu::engraving::AutomationPointEdit::MovePoint;
      55              : using ErasePoint = mu::engraving::AutomationPointEdit::ErasePoint;
      56              : 
      57              : constexpr static qreal POLYLINE_LINE_WIDTH = 1.5;
      58              : 
      59              : constexpr static qreal POLYLINE_STANDARD_CENTER_RADIUS = 3.0;
      60              : constexpr static qreal POLYLINE_HOVERED_CENTER_RADIUS = 3.5;
      61              : constexpr static qreal POLYLINE_SELECTED_CENTER_RADIUS = 3.5;
      62              : 
      63              : constexpr static qreal POLYLINE_SELECTED_MIDDLE_RING_WIDTH = 1.5;
      64              : 
      65              : constexpr static int POLYLINE_SELECTED_HOVERED_ALPHA = 127;
      66              : constexpr static int POLYLINE_GENERATED_AREA_ALPHA = 51;
      67              : constexpr static int POLYLINE_EDITED_AREA_ALPHA = 102;
      68              : 
      69            0 : static bool polylinePointIndexIsValid(const PolylinePlot* polyline, int pointIdx)
      70              : {
      71            0 :     IF_ASSERT_FAILED(polyline) {
      72            0 :         return false;
      73              :     }
      74            0 :     return pointIdx > -1 && pointIdx < static_cast<int>(polyline->points().size());
      75              : }
      76              : 
      77              : // Rescale between the common dynamic value range [PPPP, FFFF] and the full [0, 1] display range
      78              : // (the staff box), so those points fill the staff
      79              : static const muse::real_t DYNAMICS_DISPLAY_RANGE_MIN = mu::engraving::ORDINARY_DYNAMIC_VALUES.at(mu::engraving::DynamicType::PPPP);
      80              : static const muse::real_t DYNAMICS_DISPLAY_RANGE_MAX = mu::engraving::ORDINARY_DYNAMIC_VALUES.at(mu::engraving::DynamicType::FFFF);
      81              : 
      82              : // Must match muse::audio::VOLUME_DB_MIN/MAX
      83              : static constexpr double VOLUME_RANGE_MIN_DB = -60.0;
      84              : static constexpr double VOLUME_RANGE_MAX_DB = 12.0;
      85              : 
      86              : // Mirrors VolumeSlider.qml's fader curve, so dragging a point feels like moving the mixer fader -
      87              : // a plain linear map would put 0dB at 83% up the lane instead of the center
      88              : static constexpr double VOLUME_LOCAL_CENTER_DB = -24.0;
      89              : static constexpr double VOLUME_LOGICAL_CENTER_DB = -12.0;
      90              : static constexpr double VOLUME_HIGH_ACCURACY_STEP = 1.5;
      91              : static constexpr double VOLUME_LOW_ACCURACY_STEP = 0.75;
      92              : 
      93              : // logical (actual) dB -> local (linear-in-display) dB
      94            0 : static double volumeLogicalDbToLocalDb(double logicalDb)
      95              : {
      96            0 :     if (logicalDb > VOLUME_LOGICAL_CENTER_DB) {
      97            0 :         const double diff = VOLUME_RANGE_MAX_DB - logicalDb;
      98            0 :         return VOLUME_RANGE_MAX_DB - diff * VOLUME_HIGH_ACCURACY_STEP;
      99              :     }
     100              : 
     101            0 :     const double diff = VOLUME_LOGICAL_CENTER_DB - logicalDb;
     102            0 :     return VOLUME_LOCAL_CENTER_DB - diff * VOLUME_LOW_ACCURACY_STEP;
     103              : }
     104              : 
     105              : // local (linear-in-display) dB -> logical (actual) dB
     106            0 : static double volumeLocalDbToLogicalDb(double localDb)
     107              : {
     108            0 :     if (localDb > VOLUME_LOCAL_CENTER_DB) {
     109            0 :         const double diff = VOLUME_RANGE_MAX_DB - localDb;
     110            0 :         return VOLUME_RANGE_MAX_DB - diff / VOLUME_HIGH_ACCURACY_STEP;
     111              :     }
     112              : 
     113            0 :     const double diff = VOLUME_LOCAL_CENTER_DB - localDb;
     114            0 :     return VOLUME_LOGICAL_CENTER_DB - diff / VOLUME_LOW_ACCURACY_STEP;
     115              : }
     116              : 
     117              : // Mirrors the volume fader curve above, anchored on the default tempo instead of 0dB - a plain
     118              : // linear map would put the default tempo (120bpm) at ~12% up the lane instead of the center
     119              : // This is a UI-only display cap - the score model still allows tempos up to Constants::MAX_TEMPO
     120              : static constexpr double TEMPO_RANGE_MAX_BPM = 300.0;
     121              : static const double TEMPO_LOGICAL_CENTER_BPM = mu::engraving::Constants::DEFAULT_TEMPO.toBPM().val;
     122              : static const double TEMPO_LOCAL_CENTER_BPM = TEMPO_RANGE_MAX_BPM / 2.0;
     123              : static const double TEMPO_HIGH_ACCURACY_STEP = (TEMPO_RANGE_MAX_BPM - TEMPO_LOCAL_CENTER_BPM)
     124              :                                                / (TEMPO_RANGE_MAX_BPM - TEMPO_LOGICAL_CENTER_BPM);
     125              : static const double TEMPO_LOW_ACCURACY_STEP = TEMPO_LOCAL_CENTER_BPM / TEMPO_LOGICAL_CENTER_BPM;
     126              : 
     127              : // logical (actual) bpm -> local (linear-in-display) bpm
     128            0 : static double tempoLogicalBpmToLocalBpm(double logicalBpm)
     129              : {
     130            0 :     if (logicalBpm > TEMPO_LOGICAL_CENTER_BPM) {
     131            0 :         const double diff = TEMPO_RANGE_MAX_BPM - logicalBpm;
     132            0 :         return TEMPO_RANGE_MAX_BPM - diff * TEMPO_HIGH_ACCURACY_STEP;
     133              :     }
     134              : 
     135            0 :     const double diff = TEMPO_LOGICAL_CENTER_BPM - logicalBpm;
     136            0 :     return TEMPO_LOCAL_CENTER_BPM - diff * TEMPO_LOW_ACCURACY_STEP;
     137              : }
     138              : 
     139              : // local (linear-in-display) bpm -> logical (actual) bpm
     140            0 : static double tempoLocalBpmToLogicalBpm(double localBpm)
     141              : {
     142            0 :     if (localBpm > TEMPO_LOCAL_CENTER_BPM) {
     143            0 :         const double diff = TEMPO_RANGE_MAX_BPM - localBpm;
     144            0 :         return TEMPO_RANGE_MAX_BPM - diff / TEMPO_HIGH_ACCURACY_STEP;
     145              :     }
     146              : 
     147            0 :     const double diff = TEMPO_LOCAL_CENTER_BPM - localBpm;
     148            0 :     return TEMPO_LOGICAL_CENTER_BPM - diff / TEMPO_LOW_ACCURACY_STEP;
     149              : }
     150              : 
     151              : // Values are stored normalized [0, 1]; Dynamics rescales that into its own sub-range for display,
     152              : // Volume and Tempo additionally apply a fader curve, other types map 1:1 onto the display range
     153            0 : static double automationValueToDisplay(AutomationType type, muse::real_t value)
     154              : {
     155            0 :     if (type == AutomationType::Dynamics) {
     156            0 :         const double display = (value - DYNAMICS_DISPLAY_RANGE_MIN) / (DYNAMICS_DISPLAY_RANGE_MAX - DYNAMICS_DISPLAY_RANGE_MIN);
     157            0 :         return std::clamp(display, 0.0, 1.0);
     158              :     }
     159              : 
     160            0 :     if (type == AutomationType::Volume) {
     161            0 :         const double logicalDb = VOLUME_RANGE_MIN_DB + static_cast<double>(value) * (VOLUME_RANGE_MAX_DB - VOLUME_RANGE_MIN_DB);
     162            0 :         const double localDb = volumeLogicalDbToLocalDb(logicalDb);
     163            0 :         const double display = (localDb - VOLUME_RANGE_MIN_DB) / (VOLUME_RANGE_MAX_DB - VOLUME_RANGE_MIN_DB);
     164            0 :         return std::clamp(display, 0.0, 1.0);
     165              :     }
     166              : 
     167            0 :     if (type == AutomationType::Tempo) {
     168            0 :         const double logicalBpm = mu::engraving::denormalizeTempo(value).toBPM().val;
     169            0 :         const double localBpm = tempoLogicalBpmToLocalBpm(logicalBpm);
     170            0 :         const double display = localBpm / TEMPO_RANGE_MAX_BPM;
     171            0 :         return std::clamp(display, 0.0, 1.0);
     172              :     }
     173              : 
     174            0 :     return std::clamp(static_cast<double>(value), 0.0, 1.0);
     175              : }
     176              : 
     177              : // existingValue preserves Tempo values above the display cap when the drag lands back at the same clamped edge
     178            0 : static muse::real_t automationValueFromDisplay(AutomationType type, double displayValue,
     179              :                                                std::optional<muse::real_t> existingValue = std::nullopt)
     180              : {
     181            0 :     if (type == AutomationType::Dynamics) {
     182            0 :         return DYNAMICS_DISPLAY_RANGE_MIN + displayValue * (DYNAMICS_DISPLAY_RANGE_MAX - DYNAMICS_DISPLAY_RANGE_MIN);
     183              :     }
     184              : 
     185            0 :     if (type == AutomationType::Volume) {
     186            0 :         const double localDb = VOLUME_RANGE_MIN_DB + displayValue * (VOLUME_RANGE_MAX_DB - VOLUME_RANGE_MIN_DB);
     187            0 :         const double logicalDb = volumeLocalDbToLogicalDb(localDb);
     188            0 :         const double normalized = (logicalDb - VOLUME_RANGE_MIN_DB) / (VOLUME_RANGE_MAX_DB - VOLUME_RANGE_MIN_DB);
     189            0 :         return muse::real_t(normalized);
     190              :     }
     191              : 
     192            0 :     if (type == AutomationType::Tempo) {
     193            0 :         if (existingValue && muse::RealIsEqualOrMore(displayValue, 1.0)
     194            0 :             && muse::RealIsEqualOrMore(automationValueToDisplay(type, *existingValue), 1.0)) {
     195            0 :             return *existingValue;
     196              :         }
     197              : 
     198            0 :         const double localBpm = displayValue * TEMPO_RANGE_MAX_BPM;
     199            0 :         const double logicalBpm = tempoLocalBpmToLogicalBpm(localBpm);
     200            0 :         const muse::real_t normalized = mu::engraving::normalizeTempo(mu::engraving::BeatsPerSecond::fromBPM(logicalBpm));
     201            0 :         return std::clamp(normalized, mu::engraving::MIN_NORMALIZED_TEMPO, mu::engraving::MAX_NORMALIZED_TEMPO);
     202              :     }
     203              : 
     204            0 :     return muse::real_t(displayValue);
     205              : }
     206              : 
     207            0 : static const Segment* lastSegmentOfSystem(const System* system)
     208              : {
     209            0 :     const mu::engraving::SegmentType type = mu::engraving::SegmentType::Duration;
     210            0 :     const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(type) : nullptr;
     211            0 :     const Segment* last = nullptr;
     212            0 :     while (seg && seg->system() == system) {
     213            0 :         last = seg;
     214            0 :         seg = seg->next1(type);
     215              :     }
     216            0 :     return last;
     217              : }
     218              : 
     219              : // Maps an x position to a tick via linear interpolation between the nearest Duration/barline segments on either side of it
     220            0 : static std::optional<int> tickFromCanvasX(const System* system, const muse::RectF& staffCanvasRect, qreal x)
     221              : {
     222            0 :     IF_ASSERT_FAILED(system) {
     223            0 :         return std::nullopt;
     224              :     }
     225              : 
     226            0 :     const double pointCanvasX = staffCanvasRect.x() + x * staffCanvasRect.width();
     227            0 :     const mu::engraving::SegmentType type = mu::engraving::SegmentType::Duration | mu::engraving::SegmentType::BarLineTypes;
     228              : 
     229            0 :     const Segment* prevSeg = nullptr;
     230            0 :     const Segment* nextSeg = nullptr;
     231            0 :     for (const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(type) : nullptr;
     232            0 :          seg && seg->system() == system; seg = seg->next1(type)) {
     233            0 :         if (seg->canvasX() <= pointCanvasX) {
     234            0 :             prevSeg = seg;
     235              :         } else {
     236            0 :             nextSeg = seg;
     237            0 :             break;
     238              :         }
     239              :     }
     240              : 
     241            0 :     if (!prevSeg) {
     242            0 :         return nextSeg ? std::make_optional(nextSeg->tick().ticks()) : std::nullopt;
     243              :     }
     244              : 
     245              :     // No next segment - use prevSeg's own end as a virtual next point
     246            0 :     const double nextCanvasX = nextSeg ? nextSeg->canvasX() : prevSeg->canvasX() + prevSeg->width();
     247            0 :     const int nextTick = nextSeg ? nextSeg->tick().ticks() : prevSeg->tick().ticks() + prevSeg->ticks().ticks();
     248            0 :     const double canvasSpan = nextCanvasX - prevSeg->canvasX();
     249            0 :     const double ratio = canvasSpan > 0.0 ? (pointCanvasX - prevSeg->canvasX()) / canvasSpan : 0.0;
     250              : 
     251            0 :     return prevSeg->tick().ticks() + static_cast<int>(ratio * (nextTick - prevSeg->tick().ticks()));
     252              : }
     253              : 
     254            0 : static AutomationCurveKey curveKeyFor(AutomationType type, const Staff* staff)
     255              : {
     256            0 :     switch (type) {
     257            0 :     case AutomationType::Dynamics:
     258            0 :         return AutomationCurveKey::staff(type, staff->id());
     259            0 :     case AutomationType::Tempo:
     260            0 :         return AutomationCurveKey::global(type);
     261            0 :     case AutomationType::Volume:
     262              :     case AutomationType::Pan: {
     263            0 :         const Part* part = staff->part();
     264            0 :         const InstrumentTrackId trackId { part->id(), part->instrumentId() };
     265            0 :         return AutomationCurveKey::instrument(type, trackId);
     266            0 :     }
     267            0 :     case AutomationType::Unknown:
     268            0 :         break;
     269              :     }
     270              : 
     271            0 :     return AutomationCurveKey();
     272              : }
     273              : 
     274            0 : static staff_idx_t firstVisibleStaffIdx(const Score* score)
     275              : {
     276            0 :     for (staff_idx_t i = 0; i < score->nstaves(); ++i) {
     277            0 :         if (score->staff(i)->show()) {
     278            0 :             return i;
     279              :         }
     280              :     }
     281              : 
     282            0 :     return muse::nidx;
     283              : }
     284              : 
     285            0 : static bool isStructuralChange(const mu::engraving::ScoreChanges& changes)
     286              : {
     287            0 :     if (!changes.changedObjects.empty() && !changes.isValidBoundary()) {
     288            0 :         return true;
     289              :     }
     290              : 
     291              :     static const std::unordered_set<mu::engraving::ElementType> STRUCTURAL_TYPES {
     292              :         mu::engraving::ElementType::MEASURE,
     293              :         mu::engraving::ElementType::PART,
     294            0 :     };
     295              : 
     296            0 :     for (const mu::engraving::ElementType type : changes.changedTypes) {
     297            0 :         if (muse::contains(STRUCTURAL_TYPES, type)) {
     298            0 :             return true;
     299              :         }
     300              :     }
     301              : 
     302            0 :     return false;
     303              : }
     304              : 
     305            0 : static qreal defaultValueFor(AutomationType type)
     306              : {
     307            0 :     if (type == AutomationType::Pan) {
     308            0 :         return 0.5;
     309              :     }
     310            0 :     return 0.0;
     311              : }
     312              : 
     313            0 : NotationAutomationController::NotationAutomationController(QQuickItem* linesParent, const muse::modularity::ContextPtr& iocCtx)
     314            0 :     : muse::Contextable(iocCtx), m_linesParent(linesParent)
     315              : {
     316            0 : }
     317              : 
     318            0 : void NotationAutomationController::init()
     319              : {
     320            0 :     IF_ASSERT_FAILED(automation() && currentNotation()) {
     321            0 :         return;
     322              :     }
     323              : 
     324            0 :     onCurrentNotationChanged();
     325              : 
     326            0 :     automation()->automationModeEnabledChanged().onNotify(this, [this]() {
     327            0 :         if (automation()->isAutomationModeEnabled() && !m_pendingChanges.isEmpty()) {
     328            0 :             applyAutomationChanges(m_pendingChanges);
     329            0 :             m_pendingChanges.clear();
     330              :         } else {
     331            0 :             updatePolylinesGeometry();
     332              :         }
     333            0 :     }, Asyncable::Mode::SetReplace /* FIXME */);
     334              : 
     335            0 :     automation()->resetEditStateRequested().onNotify(this, [this]() {
     336            0 :         if (m_currentlyEditedPolyline) {
     337            0 :             m_currentlyEditedPolyline->cancelEdit();
     338              :             // Rebuild all because changes to this polyline may have affected those on other systems...
     339              :             // TODO: As above - e.g. changes to the end of a polyline in one system should affect the polyline on
     340              :             // the next system (probably requires the implementation of "startY" in PolylinePlot)...
     341            0 :             rebuildAllPolylines();
     342              :         }
     343            0 :         m_currentlyEditedPolyline = nullptr;
     344            0 :     }, Asyncable::Mode::SetReplace /* FIXME */);
     345              : 
     346            0 :     notationConfiguration()->currentAutomationTypeChanged().onNotify(this, [this]() {
     347            0 :         rebuildAllPolylines();
     348            0 :     }, Asyncable::Mode::SetReplace /* FIXME */);
     349              : 
     350            0 :     projectSession()->currentNotationChanged().onNotify(this, [this]() {
     351            0 :         onCurrentNotationChanged();
     352            0 :     }, Asyncable::Mode::SetReplace /* FIXME */);
     353              : 
     354            0 :     notationConfiguration()->scoreInversionChanged().onNotify(this, [this]() {
     355            0 :         updatePolylinesColors();
     356            0 :     }, Asyncable::Mode::SetReplace /* FIXME */);
     357              : 
     358            0 :     notationConfiguration()->isOnlyInvertInDarkThemeChanged().onNotify(this, [this]() {
     359            0 :         updatePolylinesColors();
     360            0 :     }, Asyncable::Mode::SetReplace /* FIXME */);
     361              : 
     362            0 :     uiConfiguration()->currentThemeChanged().onNotify(this, [this]() {
     363            0 :         updatePolylinesColors();
     364            0 :     }, Asyncable::Mode::SetReplace /* FIXME */);
     365              : 
     366            0 :     engravingConfiguration()->selectionColorChanged().onReceive(this, [this](voice_idx_t idx, const muse::draw::Color&) {
     367              :         // voice 1 color is used for the centre of selected points, "all voices color" is used for the area under lines
     368            0 :         if (idx == 0 || idx == mu::engraving::VOICES) {
     369            0 :             updatePolylinesColors();
     370              :         }
     371            0 :     }, Asyncable::Mode::SetReplace /* FIXME */);
     372              : }
     373              : 
     374            0 : NotationAutomationController::SysStaffToPolylinesMap NotationAutomationController::createPolylinesForSystem(const System* system)
     375              : {
     376            0 :     IF_ASSERT_FAILED(system && m_linesParent && score()) {
     377            0 :         return {};
     378              :     }
     379              : 
     380            0 :     SysStaffToPolylinesMap map;
     381              : 
     382            0 :     staff_idx_t staffIdx = system->firstVisibleStaff();
     383            0 :     while (staffIdx != muse::nidx) {
     384            0 :         PolylinePlot* polyline = createPolylineForStaff(system, staffIdx);
     385            0 :         if (polyline) {
     386            0 :             map.emplace(SysStaffKey(system, staffIdx), PolylinesSet({ polyline }));
     387              :         }
     388            0 :         staffIdx = system->nextVisibleStaff(staffIdx);
     389              :     }
     390              : 
     391            0 :     return map;
     392            0 : }
     393              : 
     394            0 : muse::uicomponents::PolylinePlot* NotationAutomationController::createPolylineForStaff(const System* system, staff_idx_t staffIdx)
     395              : {
     396            0 :     IF_ASSERT_FAILED(system && m_linesParent && score()) {
     397            0 :         return nullptr;
     398              :     }
     399              : 
     400            0 :     const Staff* staff = score()->staff(staffIdx);
     401            0 :     const SysStaff* sysStaff = system->staff(staffIdx);
     402            0 :     if (!staff || !sysStaff || !staff->isPrimaryStaff()) {
     403            0 :         return nullptr;
     404              :     }
     405              : 
     406            0 :     const AutomationCurveKey curveKey = curveKeyFor(currentAutomationType(), staff);
     407            0 :     if (curveKey.isGlobal() && staffIdx != firstVisibleStaffIdx(score())) {
     408              :         // Score-scoped automation is only drawn on the score's first staff
     409            0 :         return nullptr;
     410              :     }
     411              : 
     412            0 :     if (curveKey.trackId().has_value() && !staff->isTop()) {
     413              :         // Instrument-scoped automation is only drawn on the instrument's first staff
     414            0 :         return nullptr;
     415              :     }
     416              : 
     417            0 :     const int systemStartTick = system->first()->tick().ticks();
     418            0 :     const int systemEndTick = system->last()->endTick().ticks();
     419              : 
     420            0 :     const Measure* firstMeasure = system->firstMeasure();
     421            0 :     const Segment* firstSeg = firstMeasure ? firstMeasure->first(mu::engraving::SegmentType::Duration) : nullptr;
     422            0 :     const Segment* lastSeg = lastSegmentOfSystem(system);
     423              : 
     424              :     // TODO: Staves can have multiple polylines due to horizontal frames, at the moment we're
     425              :     // providing a single polyline over the entire staff...
     426            0 :     PolylinePlot* polyline = new PolylinePlot(m_linesParent);
     427              : 
     428            0 :     const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos());
     429            0 :     const QVector<PointData> pointsData = pointsDataInStaff(staff, staffCanvasRect, systemStartTick, systemEndTick);
     430              : 
     431            0 :     const SysStaffKey key(system, staffIdx);
     432            0 :     m_pointsDataByStaff[key] = pointsData;
     433              : 
     434              :     //! NOTE: There can't be a 1-to-1 match between the number of points in the automation model and
     435              :     //! points on the polyline. A point with equal in/out values (i.e. a "BOTH" point) is represented
     436              :     //! as 1 polyline point, whereas a point with different in/out values will be represented with 2
     437              :     //! separate polyline points...
     438            0 :     QVector<QPointF> pointsForPolyline;
     439            0 :     pointsForPolyline.reserve(pointsData.size());
     440            0 :     for (const PointData& pointData : pointsData) {
     441            0 :         pointsForPolyline.emplace_back(pointData.qPointF);
     442              :     }
     443            0 :     polyline->setPoints(pointsForPolyline);
     444              : 
     445            0 :     applyPolylineStyle(polyline, key);
     446            0 :     polyline->setVisible(false);
     447              : 
     448              :     // Points can't be dragged past the system's first/last segment
     449            0 :     const qreal minX = firstSeg ? (firstSeg->canvasX() - staffCanvasRect.x()) / staffCanvasRect.width() : 0.0;
     450            0 :     const qreal maxX = lastSeg ? (lastSeg->canvasX() + lastSeg->width() - staffCanvasRect.x()) / staffCanvasRect.width() : 1.0;
     451              : 
     452            0 :     QObject::connect(polyline, &muse::uicomponents::PolylinePlot::pointMoved,
     453            0 :                      [this, key, polyline, minX, maxX](int pointIdx, qreal x, qreal y, bool completed) {
     454            0 :         IF_ASSERT_FAILED(polylinePointIndexIsValid(polyline, pointIdx)) {
     455            0 :             return;
     456              :         }
     457              : 
     458            0 :         const auto pointsDataIt = m_pointsDataByStaff.find(key);
     459            0 :         IF_ASSERT_FAILED(pointsDataIt != m_pointsDataByStaff.end() && pointIdx < pointsDataIt->second.size()) {
     460            0 :             return;
     461              :         }
     462              : 
     463            0 :         const PointData& oldPointData = pointsDataIt->second[pointIdx];
     464            0 :         const mu::engraving::AutomationPoint* automationPoint = automationPointAt(key, oldPointData.tick);
     465            0 :         const bool editRestricted = !automationPoint || automationPoint->generated || automationPoint->itemId.has_value();
     466            0 :         const qreal clampedX = editRestricted ? oldPointData.qPointF.x() : std::clamp(x, minX, maxX);
     467              : 
     468            0 :         const auto setPreviewPoint = [this, polyline, pointIdx, key](const QPointF& point) {
     469            0 :             QVector<QPointF> points = polyline->points();
     470            0 :             points.replace(pointIdx, point);
     471            0 :             polyline->setPoints(points);
     472            0 :             applyPolylineColorsUnderLine(polyline, key);
     473            0 :             polyline->update(); // TODO: pass update rect?
     474            0 :         };
     475              : 
     476            0 :         if (completed) {
     477            0 :             m_currentlyEditedPolyline = nullptr;
     478            0 :             if (!requestEditPoint(oldPointData, key, clampedX, y)) {
     479              :                 // Edit was rejected - snap the point back to where it actually is instead of
     480              :                 // leaving the live-drag preview stuck at the rejected position
     481            0 :                 setPreviewPoint(oldPointData.qPointF);
     482              :             }
     483            0 :             return;
     484              :         }
     485              : 
     486            0 :         m_currentlyEditedPolyline = polyline;
     487              : 
     488              :         // Live drag preview
     489            0 :         setPreviewPoint({ clampedX, y });
     490              :     });
     491              : 
     492            0 :     QObject::connect(polyline, &muse::uicomponents::PolylinePlot::pointAdded,
     493            0 :                      [this, key, polyline, system, staffCanvasRect](qreal x, qreal y, bool completed) {
     494            0 :         if (completed) {
     495            0 :             m_currentlyEditedPolyline = nullptr;
     496            0 :             requestAddPoint(key, x, y);
     497            0 :             return;
     498              :         }
     499              : 
     500            0 :         const std::optional<int> tick = tickFromCanvasX(system, staffCanvasRect, x);
     501            0 :         if (!tick) {
     502            0 :             return;
     503              :         }
     504              : 
     505            0 :         m_currentlyEditedPolyline = polyline;
     506              : 
     507            0 :         QVector<PointData>& pointsData = m_pointsDataByStaff[key];
     508            0 :         int insertIdx = 0;
     509            0 :         while (insertIdx < pointsData.size() && pointsData.at(insertIdx).tick < *tick) {
     510            0 :             ++insertIdx;
     511              :         }
     512            0 :         pointsData.insert(insertIdx, PointData(-1, *tick, { x, y }, PointData::PointType::BOTH));
     513              : 
     514            0 :         QVector<QPointF> points = polyline->points();
     515            0 :         points.insert(insertIdx, { x, y });
     516            0 :         polyline->setPoints(points);
     517            0 :         applyPolylineColorsUnderLine(polyline, key);
     518            0 :     });
     519              : 
     520            0 :     QObject::connect(polyline, &muse::uicomponents::PolylinePlot::pointRemoved,
     521            0 :                      [this, key](int pointIdx, bool completed) {
     522            0 :         if (!completed) {
     523            0 :             return;
     524              :         }
     525            0 :         const auto pointsDataIt = m_pointsDataByStaff.find(key);
     526            0 :         IF_ASSERT_FAILED(pointsDataIt != m_pointsDataByStaff.end() && pointIdx >= 0 && pointIdx < pointsDataIt->second.size()) {
     527            0 :             return;
     528              :         }
     529            0 :         requestRemovePoint(pointsDataIt->second.at(pointIdx), key);
     530              :     });
     531              : 
     532            0 :     return polyline;
     533            0 : }
     534              : 
     535            0 : QVector<NotationAutomationController::PointData> NotationAutomationController::pointsDataInStaff(const mu::engraving::Staff* staff,
     536              :                                                                                                  const muse::RectF& sysStaffCanvasRect,
     537              :                                                                                                  int startTick, int endTick) const
     538              : {
     539            0 :     QVector<PointData> points;
     540            0 :     IF_ASSERT_FAILED(staff && score() && automationData()) {
     541            0 :         return points;
     542              :     }
     543              : 
     544            0 :     const AutomationType type = currentAutomationType();
     545              : 
     546            0 :     int currentPointIndex = 0;
     547            0 :     const mu::engraving::AutomationCurveKey key = curveKeyFor(type, staff);
     548            0 :     const mu::engraving::AutomationCurve& curve = automationData()->curve(key);
     549              : 
     550              :     // Start at the first point >= startTick rather than curve.begin() - resolvedInValue() only ever
     551              :     // looks backward via std::prev(it), which works on any valid iterator, not just one reached by
     552              :     // walking from the beginning
     553            0 :     for (auto it = curve.lower_bound(startTick); it != curve.end(); ++it) {
     554            0 :         const int tick = it->first;
     555            0 :         const bool isPastEnd = tick > endTick;
     556              : 
     557            0 :         const Fraction frac = Fraction::fromTicks(tick);
     558            0 :         const Segment* seg = score()->tick2leftSegmentMM(frac);
     559            0 :         if (!seg) { //! FIXME: fix automation curve on measure repeats
     560            0 :             continue;
     561              :         }
     562              : 
     563              :         // The point's tick may not exactly match that of a segment. For this reason we can only calculate the x position
     564              :         // of our points based on a "tickRatio". This ratio is based on the "tick difference" between the point tick and
     565              :         // the segment's tick, and the duration of the segment (in ticks)...
     566            0 :         const int tickDiff = tick - seg->tick().ticks();
     567            0 :         const double tickRatio = static_cast<double>(tickDiff) / seg->ticks().ticks();
     568            0 :         const double pointXInSeg = tickRatio * seg->width(); // The point's x relative to the segment
     569              : 
     570            0 :         const double segXInStaff = seg->canvasX() - sysStaffCanvasRect.x(); // The segment's x relative to the staff
     571            0 :         const double pointXInStaff = (segXInStaff + pointXInSeg) / sysStaffCanvasRect.width();
     572              : 
     573              :         // Point in/out values are rescaled to the display range - higher value == lower Y...
     574            0 :         const mu::engraving::AutomationPoint& autoPoint = it->second;
     575            0 :         const mu::engraving::real_t resolvedIn = mu::engraving::resolveInValue(curve, it);
     576            0 :         if (resolvedIn == autoPoint.value.outValue) {
     577            0 :             const QPointF qpf(pointXInStaff, 1.0 - automationValueToDisplay(type, resolvedIn));
     578            0 :             points.emplace_back(PointData(currentPointIndex++, tick, qpf, PointData::PointType::BOTH));
     579              :         } else {
     580            0 :             const QPointF qpfIn(pointXInStaff, 1.0 - automationValueToDisplay(type, resolvedIn));
     581            0 :             points.emplace_back(PointData(currentPointIndex++, tick, qpfIn, PointData::PointType::IN));
     582              : 
     583            0 :             const QPointF qpfOut(pointXInStaff, 1.0 - automationValueToDisplay(type, autoPoint.value.outValue));
     584            0 :             points.emplace_back(PointData(currentPointIndex++, tick, qpfOut, PointData::PointType::OUT));
     585              :         }
     586              : 
     587            0 :         if (isPastEnd) {
     588              :             // Included one point past the range - its own resolved value may depend on the outValue
     589              :             // of the last in-range point, which could have just changed
     590            0 :             break;
     591              :         }
     592              :     }
     593              : 
     594            0 :     return points;
     595            0 : }
     596              : 
     597            0 : void NotationAutomationController::applyPolylineStyle(PolylinePlot* polyline, const SysStaffKey& key) const
     598              : {
     599            0 :     IF_ASSERT_FAILED(polyline) {
     600            0 :         return;
     601              :     }
     602              : 
     603            0 :     polyline->setLineWidth(POLYLINE_LINE_WIDTH);
     604            0 :     polyline->setDrawBackground(false);
     605              : 
     606            0 :     polyline->setBaselineN(defaultValueFor(currentAutomationType()));
     607              : 
     608            0 :     polyline->setGhostPointsEnabled(false);
     609            0 :     polyline->setSelectedPointsEnabled(true);
     610              : 
     611            0 :     PolylinePointStyle* standard = polyline->standardPointStyle();
     612            0 :     standard->setCenterRadius(POLYLINE_STANDARD_CENTER_RADIUS);
     613            0 :     standard->setOutlineWidth(POLYLINE_LINE_WIDTH);
     614              : 
     615            0 :     standard->setCenterRadiusHovered(POLYLINE_HOVERED_CENTER_RADIUS);
     616            0 :     standard->setOutlineWidthHovered(POLYLINE_LINE_WIDTH);
     617              : 
     618            0 :     PolylinePointStyle* selected = polyline->selectedPointStyle();
     619            0 :     selected->setCenterRadius(POLYLINE_SELECTED_CENTER_RADIUS);
     620            0 :     selected->setMiddleRingWidth(POLYLINE_SELECTED_MIDDLE_RING_WIDTH);
     621            0 :     selected->setOutlineWidth(POLYLINE_LINE_WIDTH);
     622              : 
     623            0 :     selected->setCenterRadiusHovered(POLYLINE_SELECTED_CENTER_RADIUS);
     624            0 :     selected->setMiddleRingWidthHovered(POLYLINE_SELECTED_MIDDLE_RING_WIDTH);
     625            0 :     selected->setOutlineWidthHovered(POLYLINE_LINE_WIDTH);
     626              : 
     627            0 :     applyPolylineColors(polyline, key);
     628              : }
     629              : 
     630            0 : void NotationAutomationController::applyPolylineColors(PolylinePlot* polyline, const SysStaffKey& key) const
     631              : {
     632            0 :     IF_ASSERT_FAILED(polyline) {
     633            0 :         return;
     634              :     }
     635              : 
     636            0 :     const QColor lineColor = inversionRelativeColor(muse::ui::FONT_PRIMARY_COLOR);
     637            0 :     polyline->setLineColor(lineColor);
     638              : 
     639            0 :     const QColor foregroundColor = notationConfiguration()->foregroundColor();
     640              : 
     641            0 :     PolylinePointStyle* standard = polyline->standardPointStyle();
     642            0 :     standard->setCenterColor(foregroundColor);
     643            0 :     standard->setOutlineColor(lineColor);
     644              : 
     645            0 :     standard->setCenterColorHovered(inversionRelativeColor(muse::ui::BUTTON_COLOR));
     646            0 :     standard->setOutlineColorHovered(lineColor);
     647              : 
     648            0 :     QColor selectionColor = engravingConfiguration()->selectionColor().toQColor();
     649              : 
     650            0 :     PolylinePointStyle* selected = polyline->selectedPointStyle();
     651            0 :     selected->setCenterColor(selectionColor);
     652            0 :     selected->setMiddleRingColor(foregroundColor);
     653            0 :     selected->setOutlineColor(lineColor);
     654              : 
     655            0 :     selectionColor.setAlpha(POLYLINE_SELECTED_HOVERED_ALPHA);
     656            0 :     selected->setCenterColorHovered(selectionColor);
     657            0 :     selected->setMiddleRingColorHovered(foregroundColor);
     658            0 :     selected->setOutlineColorHovered(lineColor);
     659              : 
     660            0 :     applyPolylineColorsUnderLine(polyline, key);
     661              : }
     662              : 
     663            0 : void NotationAutomationController::applyPolylineColorsUnderLine(PolylinePlot* polyline, const SysStaffKey& key) const
     664              : {
     665            0 :     IF_ASSERT_FAILED(polyline) {
     666            0 :         return;
     667              :     }
     668              : 
     669            0 :     const auto pointsDataIt = m_pointsDataByStaff.find(key);
     670            0 :     IF_ASSERT_FAILED(pointsDataIt != m_pointsDataByStaff.end()) {
     671            0 :         return;
     672              :     }
     673              : 
     674              :     // TODO: Cache these colors?
     675            0 :     const QColor allVoicesColor = engravingConfiguration()->selectionColor(mu::engraving::VOICES).toQColor();
     676              : 
     677            0 :     QColor generatedColor = allVoicesColor;
     678            0 :     generatedColor.setAlpha(POLYLINE_GENERATED_AREA_ALPHA);
     679              : 
     680            0 :     QColor editedColor = allVoicesColor;
     681            0 :     editedColor.setAlpha(POLYLINE_EDITED_AREA_ALPHA);
     682              : 
     683            0 :     const QVector<PointData>& pointsData = pointsDataIt->second;
     684              : 
     685            0 :     QVector<QColor> colorsUnderLine;
     686            0 :     colorsUnderLine.reserve(pointsData.size() + 1); // +1 for the "trailing color" (see below)
     687              : 
     688            0 :     bool prevPointGenerated = true;
     689            0 :     for (const PointData& pointData : pointsData) {
     690              :         //! NOTE: The following can be null for newly created (always non-generated) points because they're not in the model yet
     691            0 :         const mu::engraving::AutomationPoint* automationPoint = automationPointAt(key, pointData.tick);
     692            0 :         const bool currPointGenerated = automationPoint && automationPoint->generated;
     693              : 
     694              :         // Colors either side of an edited point should use the "edited color"...
     695            0 :         const bool useEditedColor = !prevPointGenerated || !currPointGenerated;
     696            0 :         colorsUnderLine.emplace_back(useEditedColor ? editedColor : generatedColor);
     697              : 
     698            0 :         prevPointGenerated = currPointGenerated;
     699              :     }
     700              : 
     701              :     // This is the trailing color (after the last point) - it always follows the color of the last point...
     702            0 :     colorsUnderLine.emplace_back(prevPointGenerated ? generatedColor : editedColor);
     703              : 
     704            0 :     polyline->setColorsUnderLine(colorsUnderLine);
     705            0 : }
     706              : 
     707            0 : QColor NotationAutomationController::inversionRelativeColor(const muse::ui::ThemeStyleKey& key) const
     708              : {
     709              :     // This method is necessary because automation colors are relative to the score inversion as opposed to the current UI theme. In an
     710              :     // inverted score we use dark theme colors, and in a non-inverted score we use light theme colors...
     711              : 
     712              :     // TODO: High contrast colors should actually be fully customizable (issue #34154)
     713            0 :     const bool isHighContrast = uiConfiguration()->isHighContrast();
     714            0 :     const muse::ui::ThemeCode lightTheme = isHighContrast ? muse::ui::HIGH_CONTRAST_WHITE_THEME_CODE : muse::ui::LIGHT_THEME_CODE;
     715            0 :     const muse::ui::ThemeCode darkTheme = isHighContrast ? muse::ui::HIGH_CONTRAST_BLACK_THEME_CODE : muse::ui::DARK_THEME_CODE;
     716              : 
     717            0 :     const bool inverted = notationConfiguration()->shouldInvertScore();
     718              : 
     719            0 :     const muse::ui::ThemeList& themes = uiConfiguration()->themes();
     720            0 :     for (const muse::ui::ThemeInfo& theme : themes) {
     721              :         // Set line colors based on score inversion as opposed to current UI themes...
     722            0 :         const bool foundLightTheme = !inverted && theme.codeKey == lightTheme;
     723            0 :         const bool foundDarkTheme = inverted && theme.codeKey == darkTheme;
     724            0 :         if (foundLightTheme || foundDarkTheme) {
     725            0 :             return theme.values[key].toString();
     726              :         }
     727              :     }
     728              : 
     729            0 :     ASSERT_X("Error scanning themes");
     730              : 
     731              :     return QColor();
     732            0 : }
     733              : 
     734            0 : void NotationAutomationController::updatePolylinesGeometry()
     735              : {
     736            0 :     const bool visible = automation() && automation()->isAutomationModeEnabled();
     737              : 
     738            0 :     for (const auto& [key, polylines] : m_stavesToLinesMap) {
     739            0 :         IF_ASSERT_FAILED(key.isValid() && !polylines.empty()) {
     740            0 :             continue;
     741              :         }
     742              : 
     743              :         // TODO: Staves can have multiple polylines due to horizontal frames, at the moment we're
     744              :         // providing a single polyline over the entire staff...
     745            0 :         PolylinePlot* polyline = *polylines.begin();
     746            0 :         polyline->setVisible(visible);
     747            0 :         if (!visible) {
     748            0 :             continue;
     749              :         }
     750              : 
     751            0 :         const SysStaff* sysStaff = key.system->staff(key.staffIdx);
     752            0 :         IF_ASSERT_FAILED(sysStaff) {
     753            0 :             continue;
     754              :         }
     755              : 
     756              :         //! NOTE: Here we should only update properties of the polyline that change relative to the view matrix. Polyline points are
     757              :         //! placed relative to the polylines themselves, and thus do not need to be modified in here...
     758            0 :         muse::RectF staffCanvasRect = sysStaff->bbox().translated(key.system->canvasPos());
     759            0 :         staffCanvasRect = m_viewMatrix.map(staffCanvasRect);
     760              : 
     761            0 :         polyline->setWidth(staffCanvasRect.width());
     762            0 :         polyline->setHeight(staffCanvasRect.height());
     763            0 :         polyline->setX(staffCanvasRect.x());
     764            0 :         polyline->setY(staffCanvasRect.y());
     765              : 
     766            0 :         applyPolylineColors(polyline, key);
     767              :     }
     768            0 : }
     769              : 
     770            0 : void NotationAutomationController::updatePolylinesColors()
     771              : {
     772            0 :     for (const auto& [key, polylines] : m_stavesToLinesMap) {
     773            0 :         IF_ASSERT_FAILED(key.isValid() && !polylines.empty()) {
     774            0 :             continue;
     775              :         }
     776              :         // TODO: Staves can have multiple polylines due to horizontal frames, at the moment we're
     777              :         // providing a single polyline over the entire staff...
     778            0 :         PolylinePlot* polyline = *polylines.begin();
     779            0 :         applyPolylineColors(polyline, key);
     780              :     }
     781            0 : }
     782              : 
     783            0 : void NotationAutomationController::setViewMatrix(const muse::draw::Transform& viewMatrix)
     784              : {
     785            0 :     if (viewMatrix == m_viewMatrix) {
     786            0 :         return;
     787              :     }
     788            0 :     m_viewMatrix = viewMatrix;
     789              : 
     790            0 :     if (automation() && automation()->isAutomationModeEnabled()) {
     791            0 :         updatePolylinesGeometry();
     792              :     }
     793              : }
     794              : 
     795            0 : void NotationAutomationController::onCurrentNotationChanged()
     796              : {
     797            0 :     m_pendingChanges.clear();
     798            0 :     m_pendingScoreState = PendingScoreState();
     799            0 :     rebuildAllPolylines();
     800              : 
     801            0 :     if (automationData()) {
     802            0 :         automationData()->changed().onReceive(this, [this](const mu::engraving::AutomationChanges& changes) {
     803            0 :             mergePendingChanges(changes);
     804            0 :             scheduleUpdate();
     805            0 :         }, Asyncable::Mode::SetReplace /* FIXME */);
     806              :     }
     807              : 
     808            0 :     if (score()) {
     809            0 :         score()->changesChannel().onReceive(this, [this](const mu::engraving::ScoreChanges& changes) {
     810            0 :             mergePendingScoreChanges(changes);
     811            0 :             scheduleUpdate();
     812            0 :         }, Asyncable::Mode::SetReplace /* FIXME */);
     813              :     }
     814              : 
     815            0 :     if (currentNotation()) {
     816            0 :         currentNotation()->viewModeChanged().onNotify(this, [this]() {
     817            0 :             rebuildAllPolylines();
     818            0 :         }, Asyncable::Mode::SetReplace /* FIXME */);
     819              :     }
     820            0 : }
     821              : 
     822            0 : void NotationAutomationController::mergePendingScoreChanges(const mu::engraving::ScoreChanges& changes)
     823              : {
     824            0 :     const bool firstChange = !m_pendingScoreState.hasChanges;
     825            0 :     m_pendingScoreState.hasChanges = true;
     826            0 :     m_pendingScoreState.structural = m_pendingScoreState.structural || isStructuralChange(changes);
     827              : 
     828            0 :     if (!changes.isValidBoundary()) {
     829            0 :         m_pendingScoreState.boundary = std::nullopt;
     830            0 :         return;
     831              :     }
     832            0 :     if (!firstChange && !m_pendingScoreState.boundary) {
     833            0 :         return;
     834              :     }
     835              : 
     836            0 :     const TickStaffRange changeRange { changes.tickFrom, changes.tickTo, changes.staffIdxFrom, changes.staffIdxTo };
     837            0 :     TickStaffRange range = m_pendingScoreState.boundary.value_or(changeRange);
     838            0 :     range.tickFrom = std::min(range.tickFrom, changeRange.tickFrom);
     839            0 :     range.tickTo = std::max(range.tickTo, changeRange.tickTo);
     840            0 :     range.staffIdxFrom = std::min(range.staffIdxFrom, changeRange.staffIdxFrom);
     841            0 :     range.staffIdxTo = std::max(range.staffIdxTo, changeRange.staffIdxTo);
     842            0 :     m_pendingScoreState.boundary = range;
     843              : }
     844              : 
     845            0 : void NotationAutomationController::scheduleUpdate()
     846              : {
     847            0 :     if (m_updateScheduled) {
     848            0 :         return;
     849              :     }
     850            0 :     m_updateScheduled = true;
     851              : 
     852            0 :     muse::async::Async::call(this, [this]() {
     853            0 :         m_updateScheduled = false;
     854            0 :         processPendingChanges();
     855            0 :     });
     856              : }
     857              : 
     858            0 : void NotationAutomationController::processPendingChanges()
     859              : {
     860            0 :     if (!m_pendingScoreState.hasChanges && m_pendingChanges.isEmpty()) {
     861            0 :         return;
     862              :     }
     863            0 :     const PendingScoreState scoreState = m_pendingScoreState;
     864            0 :     m_pendingScoreState = PendingScoreState();
     865              : 
     866            0 :     const bool automationVisible = automation() && automation()->isAutomationModeEnabled();
     867              : 
     868            0 :     if (scoreState.structural) {
     869            0 :         if (!automationVisible) {
     870              :             // Nothing visible right now; defer the rebuild until automation mode is enabled again
     871            0 :             m_pendingChanges.isFullReset = true;
     872            0 :             return;
     873              :         }
     874            0 :         rebuildAllPolylines();
     875            0 :         m_pendingChanges.clear();
     876            0 :         return;
     877              :     }
     878              : 
     879            0 :     if (!automationVisible) {
     880              :         // Nothing visible right now; m_pendingChanges keeps accumulating for next time
     881            0 :         return;
     882              :     }
     883              : 
     884            0 :     if (!m_pendingChanges.isEmpty()) {
     885            0 :         applyAutomationChanges(m_pendingChanges);
     886            0 :         m_pendingChanges.clear();
     887            0 :         return;
     888              :     }
     889              : 
     890              :     // No automation-data change and nothing structural - just layout drift
     891              :     // (e.g. measure widths shifted); refresh point positions using the batch's own range
     892            0 :     for (const auto& [key, polylines] : m_stavesToLinesMap) {
     893            0 :         IF_ASSERT_FAILED(key.isValid()) {
     894            0 :             continue;
     895              :         }
     896            0 :         const Staff* staff = score()->staff(key.staffIdx);
     897            0 :         if (!staff) {
     898            0 :             continue;
     899              :         }
     900              : 
     901            0 :         const int systemStartTick = key.system->first()->tick().ticks();
     902            0 :         const int systemEndTick = key.system->last()->endTick().ticks();
     903            0 :         if (scoreState.boundary) {
     904            0 :             const TickStaffRange& range = *scoreState.boundary;
     905            0 :             if (staff->idx() < range.staffIdxFrom || staff->idx() > range.staffIdxTo) {
     906            0 :                 continue;
     907              :             }
     908            0 :             if (systemEndTick < range.tickFrom || systemStartTick > range.tickTo) {
     909            0 :                 continue;
     910              :             }
     911              :         }
     912              : 
     913            0 :         updateStaffPointsInRange(key, systemStartTick, systemEndTick);
     914              :     }
     915              : 
     916            0 :     updatePolylinesGeometry();
     917              : }
     918              : 
     919            0 : void NotationAutomationController::rebuildAllPolylines()
     920              : {
     921            0 :     m_currentlyEditedPolyline = nullptr;
     922              : 
     923              :     // TODO: More efficient if we don't clear/recreate the polylines every time...
     924            0 :     for (const auto& [staff, polylines] : m_stavesToLinesMap) {
     925            0 :         for (PolylinePlot* polyline : polylines) {
     926            0 :             delete polyline;
     927              :         }
     928              :     }
     929            0 :     m_stavesToLinesMap.clear();
     930            0 :     m_pointsDataByStaff.clear();
     931              : 
     932            0 :     if (!score()) {
     933              :         // Happens on close...
     934            0 :         return;
     935              :     }
     936              : 
     937            0 :     for (const System* system : score()->systems()) {
     938            0 :         m_stavesToLinesMap.merge(createPolylinesForSystem(system));
     939              :     }
     940              : 
     941            0 :     updatePolylinesGeometry();
     942              : }
     943              : 
     944            0 : void NotationAutomationController::updateStaffPointsInRange(const SysStaffKey& key, int tickFrom, int tickTo)
     945              : {
     946            0 :     auto mapIt = m_stavesToLinesMap.find(key);
     947            0 :     IF_ASSERT_FAILED(key.isValid() && mapIt != m_stavesToLinesMap.end() && !mapIt->second.empty()) {
     948            0 :         return;
     949              :     }
     950            0 :     PolylinePlot* polyline = *mapIt->second.begin();
     951              : 
     952            0 :     const Staff* staff = score() ? score()->staff(key.staffIdx) : nullptr;
     953            0 :     const SysStaff* sysStaff = key.system ? key.system->staff(key.staffIdx) : nullptr;
     954            0 :     IF_ASSERT_FAILED(staff && sysStaff) {
     955            0 :         return;
     956              :     }
     957              : 
     958            0 :     const muse::RectF staffCanvasRect = sysStaff->bbox().translated(key.system->canvasPos());
     959            0 :     const QVector<PointData> newRangeData = pointsDataInStaff(staff, staffCanvasRect, tickFrom, tickTo);
     960              : 
     961            0 :     QVector<PointData>& pointsData = m_pointsDataByStaff[key];
     962              : 
     963            0 :     int firstIdx = 0;
     964            0 :     while (firstIdx < pointsData.size() && pointsData.at(firstIdx).tick < tickFrom) {
     965            0 :         ++firstIdx;
     966              :     }
     967            0 :     int lastIdx = firstIdx;
     968            0 :     while (lastIdx < pointsData.size() && pointsData.at(lastIdx).tick <= tickTo) {
     969            0 :         ++lastIdx;
     970              :     }
     971            0 :     if (!newRangeData.isEmpty() && newRangeData.back().tick > tickTo) {
     972            0 :         const int trailingTick = newRangeData.back().tick;
     973            0 :         while (lastIdx < pointsData.size() && pointsData.at(lastIdx).tick == trailingTick) {
     974            0 :             ++lastIdx;
     975              :         }
     976              :     }
     977              : 
     978            0 :     QVector<PointData> updatedPointsData;
     979            0 :     updatedPointsData.reserve(pointsData.size() - (lastIdx - firstIdx) + newRangeData.size());
     980            0 :     for (int i = 0; i < firstIdx; ++i) {
     981            0 :         updatedPointsData.push_back(pointsData.at(i));
     982              :     }
     983            0 :     for (const PointData& pointData : newRangeData) {
     984            0 :         updatedPointsData.push_back(pointData);
     985              :     }
     986            0 :     for (int i = lastIdx; i < pointsData.size(); ++i) {
     987            0 :         updatedPointsData.push_back(pointsData.at(i));
     988              :     }
     989            0 :     pointsData = updatedPointsData;
     990              : 
     991            0 :     QVector<QPointF> points;
     992            0 :     points.reserve(pointsData.size());
     993            0 :     for (const PointData& pointData : pointsData) {
     994            0 :         points.push_back(pointData.qPointF);
     995              :     }
     996            0 :     polyline->setPoints(points);
     997            0 :     applyPolylineColorsUnderLine(polyline, key);
     998            0 :     polyline->update();
     999            0 : }
    1000              : 
    1001            0 : void NotationAutomationController::mergePendingChanges(const mu::engraving::AutomationChanges& changes)
    1002              : {
    1003            0 :     if (changes.isFullReset) {
    1004            0 :         m_pendingChanges.isFullReset = true;
    1005            0 :         return;
    1006              :     }
    1007            0 :     for (const mu::engraving::AutomationCurveKey& key : changes.affectedKeys) {
    1008            0 :         m_pendingChanges.extend(key, changes.tickFrom, changes.tickTo);
    1009              :     }
    1010              : }
    1011              : 
    1012            0 : void NotationAutomationController::applyAutomationChanges(const mu::engraving::AutomationChanges& changes)
    1013              : {
    1014            0 :     if (changes.isFullReset || !score()) {
    1015            0 :         rebuildAllPolylines();
    1016            0 :         return;
    1017              :     }
    1018              : 
    1019            0 :     std::set<muse::ID> affectedStaffIds;
    1020            0 :     std::set<mu::engraving::InstrumentTrackId> affectedTrackIds;
    1021            0 :     bool globalAffected = false;
    1022            0 :     for (const mu::engraving::AutomationCurveKey& key : changes.affectedKeys) {
    1023            0 :         if (const std::optional<muse::ID> staffId = key.staffId()) {
    1024            0 :             affectedStaffIds.insert(*staffId);
    1025            0 :         } else if (const std::optional<mu::engraving::InstrumentTrackId> trackId = key.trackId()) {
    1026            0 :             affectedTrackIds.insert(*trackId);
    1027            0 :         } else if (key.isGlobal()) {
    1028            0 :             globalAffected = true;
    1029            0 :         }
    1030              :     }
    1031              : 
    1032              :     // Only touch the staves that were actually affected and whose system overlaps the changed tick
    1033              :     // range, and only recompute points within that range, rather than the whole score or even the
    1034              :     // whole staff
    1035            0 :     for (const auto& [key, polylines] : m_stavesToLinesMap) {
    1036            0 :         IF_ASSERT_FAILED(key.isValid()) {
    1037            0 :             continue;
    1038              :         }
    1039            0 :         const Staff* staff = score()->staff(key.staffIdx);
    1040            0 :         if (!staff) {
    1041            0 :             continue;
    1042              :         }
    1043            0 :         const bool staffAffected = affectedStaffIds.find(staff->id()) != affectedStaffIds.end();
    1044            0 :         const mu::engraving::InstrumentTrackId staffTrackId { staff->part()->id(), staff->part()->instrumentId() };
    1045            0 :         const bool trackAffected = affectedTrackIds.find(staffTrackId) != affectedTrackIds.end();
    1046            0 :         const bool globalCurveAffected = globalAffected && curveKeyFor(currentAutomationType(), staff).isGlobal();
    1047            0 :         if (!staffAffected && !trackAffected && !globalCurveAffected) {
    1048            0 :             continue;
    1049              :         }
    1050            0 :         const System* system = key.system;
    1051            0 :         const int systemStartTick = system->first()->tick().ticks();
    1052            0 :         const int systemEndTick = system->last()->endTick().ticks();
    1053            0 :         if (systemEndTick >= changes.tickFrom && systemStartTick <= changes.tickTo) {
    1054            0 :             updateStaffPointsInRange(key, changes.tickFrom, changes.tickTo);
    1055              :         }
    1056            0 :     }
    1057              : 
    1058            0 :     updatePolylinesGeometry();
    1059            0 : }
    1060              : 
    1061            0 : bool NotationAutomationController::requestEditPoint(const PointData& oldPointData, const SysStaffKey& key, qreal x, qreal y)
    1062              : {
    1063              :     // STEP 1 - Check that all of our parameters are valid...
    1064            0 :     const PointData::PointType pointType = oldPointData.pointType;
    1065            0 :     IF_ASSERT_FAILED(key.isValid() && pointType != PointData::PointType::UNKNOWN) {
    1066            0 :         return false;
    1067              :     }
    1068            0 :     const System* system = key.system;
    1069            0 :     const SysStaff* sysStaff = system ? system->staff(key.staffIdx) : nullptr;
    1070            0 :     const Staff* staff = score() ? score()->staff(key.staffIdx) : nullptr;
    1071            0 :     IF_ASSERT_FAILED(sysStaff && staff) {
    1072            0 :         return false;
    1073              :     }
    1074              : 
    1075              :     // STEP 2 - Determine the new tick value based on the x parameter...
    1076            0 :     const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos());
    1077            0 :     const std::optional<int> newTickOpt = tickFromCanvasX(system, staffCanvasRect, x);
    1078            0 :     const int newTick = newTickOpt.value_or(oldPointData.tick);
    1079            0 :     const bool tickChanged = newTick != oldPointData.tick;
    1080              : 
    1081              :     // STEP 3 - Fetch the point being edited...
    1082            0 :     const mu::engraving::AutomationCurveKey curveKey = curveKeyFor(currentAutomationType(), staff);
    1083              : 
    1084            0 :     const mu::engraving::AutomationCurve& curve = automationData()->curve(curveKey);
    1085            0 :     const auto existingIt = curve.find(oldPointData.tick);
    1086            0 :     IF_ASSERT_FAILED(existingIt != curve.end()) {
    1087            0 :         return false;
    1088              :     }
    1089            0 :     const mu::engraving::AutomationPoint& existingPoint = existingIt->second;
    1090            0 :     const mu::engraving::real_t existingInValue = mu::engraving::resolveInValue(curve, existingIt);
    1091              : 
    1092              :     //! NOTE: Point in/out values are rescaled to the display range - higher value == lower Y...
    1093            0 :     const mu::engraving::real_t referenceValue = pointType == PointData::PointType::IN ? existingInValue : existingPoint.value.outValue;
    1094            0 :     const mu::engraving::real_t newValue = automationValueFromDisplay(currentAutomationType(), 1.0 - y, referenceValue);
    1095              : 
    1096              :     // STEP 4 - Update the point's value, and move it to the new tick if necessary...
    1097              : 
    1098              :     //! NOTE: Moving a BOTH point is the simplest case - we can update its value then simply change the tick.
    1099              :     //! Moving IN/OUT points is slightly more complex. In this case we need to set the in/out values to be
    1100              :     //! equal at oldTick (effectively converting the original point to a BOTH point) and create a new point
    1101              :     //! at newTick...
    1102              : 
    1103            0 :     if (!tickChanged || pointType == PointData::PointType::BOTH) {
    1104            0 :         mu::engraving::AutomationPoint editedPoint = existingPoint;
    1105              :         const mu::engraving::AutomationPoint::Ease preservedEase
    1106            0 :             = mu::engraving::ease(editedPoint).value_or(mu::engraving::AutomationPoint::Ease::none());
    1107            0 :         if (pointType == PointData::PointType::IN) {
    1108              :             // The user explicitly chose this arrival value; it no longer follows whatever precedes it
    1109            0 :             editedPoint.value.inValue = mu::engraving::AutomationPoint::ExplicitArrival { newValue, preservedEase };
    1110            0 :         } else if (pointType == PointData::PointType::BOTH) {
    1111            0 :             editedPoint.value.outValue = newValue;
    1112            0 :             editedPoint.value.inValue = mu::engraving::AutomationPoint::ExplicitArrival { editedPoint.value.outValue, preservedEase };
    1113              :         } else {
    1114            0 :             editedPoint.value.outValue = newValue;
    1115              :         }
    1116            0 :         editedPoint.generated = false;
    1117              : 
    1118              :         mu::engraving::AutomationPointEdits edits {
    1119            0 :             { newTick, MovePoint { editedPoint, oldPointData.tick } }
    1120            0 :         };
    1121              : 
    1122            0 :         editAutomationPoints(curveKey, edits);
    1123              : 
    1124            0 :         return true;
    1125            0 :     }
    1126              : 
    1127              :     // oldTick becomes a flat BOTH point, so its inValue is frozen to outValue at edit time (it no
    1128              :     // longer live-tracks outValue if it's edited again later)
    1129              :     const mu::engraving::AutomationPoint::Ease originalEase
    1130            0 :         = mu::engraving::ease(existingPoint).value_or(mu::engraving::AutomationPoint::Ease::none());
    1131              : 
    1132            0 :     mu::engraving::AutomationPoint updatedOldPoint = existingPoint;
    1133            0 :     if (pointType == PointData::PointType::OUT) {
    1134            0 :         updatedOldPoint.value.outValue = existingInValue;
    1135              :     }
    1136            0 :     updatedOldPoint.value.inValue = mu::engraving::AutomationPoint::ExplicitArrival { updatedOldPoint.value.outValue, originalEase };
    1137            0 :     updatedOldPoint.generated = false;
    1138              : 
    1139            0 :     mu::engraving::AutomationPoint newPoint;
    1140            0 :     newPoint.value.outValue = newValue;
    1141            0 :     newPoint.value.inValue = mu::engraving::AutomationPoint::ExplicitArrival { newPoint.value.outValue, originalEase };
    1142            0 :     newPoint.itemId = existingPoint.itemId;
    1143              : 
    1144              :     mu::engraving::AutomationPointEdits edits {
    1145            0 :         { oldPointData.tick, SetPoint { updatedOldPoint } },
    1146            0 :         { newTick, SetPoint { newPoint } }
    1147            0 :     };
    1148              : 
    1149            0 :     editAutomationPoints(curveKey, edits);
    1150              : 
    1151            0 :     return true;
    1152            0 : }
    1153              : 
    1154            0 : bool NotationAutomationController::requestAddPoint(const SysStaffKey& key, qreal x, qreal y)
    1155              : {
    1156            0 :     IF_ASSERT_FAILED(key.isValid()) {
    1157            0 :         return false;
    1158              :     }
    1159              : 
    1160            0 :     const System* system = key.system;
    1161            0 :     const SysStaff* sysStaff = system ? system->staff(key.staffIdx) : nullptr;
    1162            0 :     const Staff* staff = score() ? score()->staff(key.staffIdx) : nullptr;
    1163            0 :     IF_ASSERT_FAILED(sysStaff && staff) {
    1164            0 :         return false;
    1165              :     }
    1166              : 
    1167            0 :     const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos());
    1168            0 :     const std::optional<int> newTick = tickFromCanvasX(system, staffCanvasRect, x);
    1169            0 :     if (!newTick) {
    1170            0 :         return false;
    1171              :     }
    1172              : 
    1173            0 :     mu::engraving::AutomationPoint newPoint;
    1174            0 :     newPoint.value.outValue = automationValueFromDisplay(currentAutomationType(), 1.0 - y);
    1175            0 :     newPoint.value.inValue = mu::engraving::AutomationPoint::ExplicitArrival { newPoint.value.outValue,
    1176            0 :                                                                                mu::engraving::AutomationPoint::Ease::none() };
    1177            0 :     newPoint.generated = false;
    1178              : 
    1179            0 :     const mu::engraving::AutomationCurveKey curveKey = curveKeyFor(currentAutomationType(), staff);
    1180              : 
    1181              :     mu::engraving::AutomationPointEdits edits {
    1182            0 :         { *newTick, SetPoint { newPoint } }
    1183            0 :     };
    1184              : 
    1185            0 :     editAutomationPoints(curveKey, edits);
    1186              : 
    1187            0 :     return true;
    1188            0 : }
    1189              : 
    1190            0 : bool NotationAutomationController::requestRemovePoint(const PointData& pointData, const SysStaffKey& key)
    1191              : {
    1192            0 :     IF_ASSERT_FAILED(key.isValid()) {
    1193            0 :         return false;
    1194              :     }
    1195              : 
    1196            0 :     const mu::engraving::AutomationPoint* automationPoint = automationPointAt(key, pointData.tick);
    1197            0 :     if (!automationPoint || automationPoint->generated || automationPoint->itemId.has_value()) {
    1198            0 :         return false;
    1199              :     }
    1200              : 
    1201            0 :     const Staff* staff = score() ? score()->staff(key.staffIdx) : nullptr;
    1202            0 :     IF_ASSERT_FAILED(staff) {
    1203            0 :         return false;
    1204              :     }
    1205              : 
    1206            0 :     const mu::engraving::AutomationCurveKey curveKey = curveKeyFor(currentAutomationType(), staff);
    1207              : 
    1208              :     mu::engraving::AutomationPointEdits edits {
    1209            0 :         { pointData.tick, ErasePoint {} }
    1210            0 :     };
    1211              : 
    1212            0 :     editAutomationPoints(curveKey, edits);
    1213              : 
    1214            0 :     return true;
    1215            0 : }
    1216              : 
    1217            0 : void NotationAutomationController::editAutomationPoints(const mu::engraving::AutomationCurveKey& key,
    1218              :                                                         mu::engraving::AutomationPointEdits& edits)
    1219              : {
    1220            0 :     const INotationAutomationPtr notationAutomation = automation();
    1221            0 :     IF_ASSERT_FAILED(notationAutomation) {
    1222            0 :         return;
    1223              :     }
    1224              : 
    1225            0 :     notationAutomation->editPoints(key, edits);
    1226            0 : }
    1227              : 
    1228            0 : const mu::engraving::AutomationPoint* NotationAutomationController::automationPointAt(const SysStaffKey& key, int tick) const
    1229              : {
    1230            0 :     const Staff* staff = score() ? score()->staff(key.staffIdx) : nullptr;
    1231            0 :     IF_ASSERT_FAILED(staff && automationData()) {
    1232            0 :         return nullptr;
    1233              :     }
    1234              : 
    1235            0 :     const mu::engraving::AutomationCurveKey curveKey = curveKeyFor(currentAutomationType(), staff);
    1236            0 :     const mu::engraving::AutomationCurve& curve = automationData()->curve(curveKey);
    1237            0 :     const auto it = curve.find(tick);
    1238            0 :     if (it == curve.end()) {
    1239            0 :         return nullptr;
    1240              :     }
    1241              : 
    1242            0 :     return &it->second;
    1243            0 : }
    1244              : 
    1245            0 : AutomationType NotationAutomationController::currentAutomationType() const
    1246              : {
    1247            0 :     return notationConfiguration()->currentAutomationType();
    1248              : }
    1249              : 
    1250            0 : INotationAutomationPtr NotationAutomationController::automation() const
    1251              : {
    1252            0 :     const IMasterNotationPtr masterNotation = projectSession()->masterNotation();
    1253            0 :     return masterNotation ? masterNotation->automation() : nullptr;
    1254            0 : }
    1255              : 
    1256            0 : INotationPtr NotationAutomationController::currentNotation() const
    1257              : {
    1258            0 :     return projectSession()->currentNotation();
    1259              : }
    1260              : 
    1261            0 : mu::engraving::AutomationDataConstPtr NotationAutomationController::automationData() const
    1262              : {
    1263            0 :     const INotationAutomationPtr notationAutomation = automation();
    1264            0 :     return notationAutomation ? notationAutomation->automationData() : nullptr;
    1265            0 : }
    1266              : 
    1267            0 : mu::engraving::Score* NotationAutomationController::score() const
    1268              : {
    1269            0 :     return currentNotation() ? currentNotation()->elements()->msScore() : nullptr;
    1270              : }
        

Generated by: LCOV version 2.0-1