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 : }
|