From 624195070904ede3e13bc62da42d01e531ad7e86 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 5 Jul 2026 14:36:25 -0400 Subject: [PATCH 1/7] fix: WING has 16 DCAs, not 24 The declared 24 sent /dca/17..24 OSC the console ignores and drew phantom columns everywhere the DCA count fans out. WING hardware (full/Compact/Rack) exposes /dca/1..16 only. --- src/protocol/MixerCapabilities.cpp | 2 +- src/protocol/behringer/WingProtocol.cpp | 4 ++-- src/ui/MixerFeedbackPanel.cpp | 2 +- tests/test_mixer_capabilities.cpp | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/protocol/MixerCapabilities.cpp b/src/protocol/MixerCapabilities.cpp index 27597ea..d463f7f 100644 --- a/src/protocol/MixerCapabilities.cpp +++ b/src/protocol/MixerCapabilities.cpp @@ -53,7 +53,7 @@ MixerCapabilities MixerCapabilities::forConsole(ConsoleType type) { caps.displayName = "Behringer WING"; caps.protocolId = "wing"; caps.defaultPort = 2223; - caps.dcaCount = 24; + caps.dcaCount = 16; caps.inputChannels = 48; caps.mixBuses = 16; caps.matrixOutputs = 8; diff --git a/src/protocol/behringer/WingProtocol.cpp b/src/protocol/behringer/WingProtocol.cpp index 7170040..6a6a339 100644 --- a/src/protocol/behringer/WingProtocol.cpp +++ b/src/protocol/behringer/WingProtocol.cpp @@ -105,8 +105,8 @@ void WingProtocol::initializeSnapshotParams() { } } - // WING has up to 24 DCAs - for (int i = 1; i <= m_capabilities.dcaCount && i <= 24; ++i) { + // WING has 16 DCAs + for (int i = 1; i <= m_capabilities.dcaCount && i <= 16; ++i) { m_snapshotParams.append(QString("/dca/%1/fader").arg(i)); m_snapshotParams.append(QString("/dca/%1/mute").arg(i)); } diff --git a/src/ui/MixerFeedbackPanel.cpp b/src/ui/MixerFeedbackPanel.cpp index c90630b..0f0d947 100644 --- a/src/ui/MixerFeedbackPanel.cpp +++ b/src/ui/MixerFeedbackPanel.cpp @@ -114,7 +114,7 @@ void MixerFeedbackPanel::connectDCASignals(DCAWidget* dca) { } void MixerFeedbackPanel::setDCACount(int count) { - count = std::clamp(count, 1, 24); // support up to 24 DCAs (WING max) + count = std::clamp(count, 1, 24); // support up to 24 DCAs (DM7/SD7 max) if (!m_dcaLayout) return; diff --git a/tests/test_mixer_capabilities.cpp b/tests/test_mixer_capabilities.cpp index 4ca6b19..0c45a3b 100644 --- a/tests/test_mixer_capabilities.cpp +++ b/tests/test_mixer_capabilities.cpp @@ -11,7 +11,7 @@ class TestMixerCapabilities : public QObject { // the console-selection feature keys the whole app's DCA count off these QCOMPARE(MixerCapabilities::forProtocolId("x32").dcaCount, 8); QCOMPARE(MixerCapabilities::forProtocolId("m32").dcaCount, 8); - QCOMPARE(MixerCapabilities::forProtocolId("wing").dcaCount, 24); + QCOMPARE(MixerCapabilities::forProtocolId("wing").dcaCount, 16); QCOMPARE(MixerCapabilities::forProtocolId("sq7").dcaCount, 8); QCOMPARE(MixerCapabilities::forProtocolId("gld80").dcaCount, 8); QCOMPARE(MixerCapabilities::forProtocolId("avantis").dcaCount, 16); @@ -31,7 +31,7 @@ class TestMixerCapabilities : public QObject { } void lookupIsCaseInsensitive() { - QCOMPARE(MixerCapabilities::forProtocolId("WING").dcaCount, 24); + QCOMPARE(MixerCapabilities::forProtocolId("WING").dcaCount, 16); QCOMPARE(MixerCapabilities::forProtocolId("Cl5").dcaCount, 16); } From 5d352da2a69661bfaaa8ee1a5408b13b3a98f0c3 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 5 Jul 2026 14:37:08 -0400 Subject: [PATCH 2/7] fix: stop doubled text while editing cue table cells The view keeps repainting a cell's text under its open inline editor, and editor opacity hung entirely on the app stylesheet cascade (the box-model-only item-view rules and the fallback sheet set no input background), so typing over an existing value drew both texts superimposed. Give inline editors an explicit opaque background and skip glyph painting for a cell whose editor is open. --- resources/styles/main.qss | 5 ++++- src/ui/CueItemDelegates.cpp | 39 +++++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/resources/styles/main.qss b/resources/styles/main.qss index fa5e5e7..7b49fcc 100644 --- a/resources/styles/main.qss +++ b/resources/styles/main.qss @@ -239,7 +239,9 @@ QLineEdit[placeholderText] { } /* inline editors inside item views must fit their row: the input box model - above (padding + min-height) otherwise overflows compact rows */ + above (padding + min-height) otherwise overflows compact rows. The explicit + background keeps them opaque — the view repaints the cell text beneath an + open editor, and a translucent editor shows both texts superimposed */ QTreeView QLineEdit, QTableView QLineEdit, QListView QLineEdit { @@ -247,6 +249,7 @@ QListView QLineEdit { border-radius: 0; padding: 0 4px; min-height: 0; + background-color: #1a1c20; } QComboBox::drop-down { diff --git a/src/ui/CueItemDelegates.cpp b/src/ui/CueItemDelegates.cpp index bf7719e..a800194 100644 --- a/src/ui/CueItemDelegates.cpp +++ b/src/ui/CueItemDelegates.cpp @@ -20,11 +20,30 @@ namespace OpenMix { namespace { // the app stylesheet's input padding and minimum height overflow a compact -// table row; strip the box model so inline cell editors fit the cell exactly +// table row; strip the box model so inline cell editors fit the cell exactly. +// the explicit background keeps the editor opaque even when the cascaded app +// stylesheet is absent (fallback sheet) — the view repaints the cell text +// beneath the editor, and a translucent editor shows both texts superimposed void fitEditorToCell(QWidget* editor) { - editor->setStyleSheet(QStringLiteral( - "QLineEdit, QComboBox { border: none; border-radius: 0; padding: 0 4px; " - "min-height: 0; }")); + editor->setStyleSheet(QStringLiteral("QLineEdit, QComboBox { border: none; " + "border-radius: 0; padding: 0 4px; " + "min-height: 0; background-color: %1; }") + .arg(QLatin1String(Theme::Colors::BgInput))); +} + +// true when the view has an inline editor open on this index. The view keeps +// repainting the cell underneath its editor; painting glyphs there produces +// doubled text whenever the editor isn't fully opaque, so callers fill only +// the row background (the editor covers the full cell rect) and skip the rest. +bool skipPaintForOpenEditor(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) { + const auto* view = qobject_cast(option.widget); + if (!view || !view->indexWidget(index)) + return false; + const QVariant bgData = index.data(Qt::BackgroundRole); + if (bgData.isValid()) + painter->fillRect(option.rect, bgData.value()); + return true; } } // namespace @@ -34,6 +53,9 @@ CueNumberDelegate::CueNumberDelegate(CueList* cueList, QObject* parent) void CueNumberDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { + if (skipPaintForOpenEditor(painter, option, index)) + return; + QStyleOptionViewItem opt = option; opt.state &= ~QStyle::State_HasFocus; @@ -138,6 +160,9 @@ CueTypeDelegate::CueTypeDelegate(QObject* parent) : QStyledItemDelegate(parent) void CueTypeDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { + if (skipPaintForOpenEditor(painter, option, index)) + return; + QStyleOptionViewItem opt = option; opt.state &= ~QStyle::State_HasFocus; @@ -278,6 +303,9 @@ DCAAssignDelegate::DCAAssignDelegate(ActorProfileLibrary* library, QObject* pare void DCAAssignDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { QStyleOptionViewItem opt = option; + if (skipPaintForOpenEditor(painter, option, index)) + return; + opt.state &= ~QStyle::State_HasFocus; QVariant bgData = index.data(Qt::BackgroundRole); @@ -350,6 +378,9 @@ CueTextDelegate::CueTextDelegate(QObject* parent) : QStyledItemDelegate(parent) void CueTextDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { QStyleOptionViewItem opt = option; + if (skipPaintForOpenEditor(painter, option, index)) + return; + opt.state &= ~QStyle::State_HasFocus; QVariant bgData = index.data(Qt::BackgroundRole); From 167daf146697dd39885a94fc642776ed8ee0a771 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 5 Jul 2026 14:37:47 -0400 Subject: [PATCH 3/7] fix: DCA overview repaints when an assignment dropdown changes The combo handlers wrote the mapping and refreshed labels/counts but never touched the overview; it only caught up on the next full rebuild (reopening the panel or switching cues). --- src/ui/DCAMappingPanel.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/DCAMappingPanel.cpp b/src/ui/DCAMappingPanel.cpp index f7527f8..6cbc03b 100644 --- a/src/ui/DCAMappingPanel.cpp +++ b/src/ui/DCAMappingPanel.cpp @@ -372,6 +372,7 @@ void DCAMappingPanel::createChannelSection() { int dca = m_channelCombos[ch - 1]->currentData().toInt(); onChannelDCAChanged(ch, dca); updateComboItemStates(); + updateDcaOverview(); } }); m_channelLayout->addWidget(combo, row, colOffset + 1); @@ -441,6 +442,7 @@ void DCAMappingPanel::createBusSection() { int dca = m_busCombos[bus - 1]->currentData().toInt(); onBusDCAChanged(bus, dca); updateComboItemStates(); + updateDcaOverview(); } }); m_busLayout->addWidget(combo, row, colOffset + 1); From 5dcb7af36ab772184a936182e8dfd7009ecce090 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 5 Jul 2026 14:51:05 -0400 Subject: [PATCH 4/7] feat: typed roles and ensembles assign all matching channels Typing a name into a cue's DCA cell never touched any mapping in the shipped app: the cue table model's actor library was only wired in tests, and resolution stopped at one actor with no ensemble support. Wire the library into the model, resolve labels in tiers (every active actor holding the role, else the same-named ensemble's channels, else actors by name, else the single-best fallback), and replace the DCA's cue-mapping members on commit so retyping swaps cleanly. Scribble labels still leave the mapping untouched. Ensemble names now autocomplete, multi-channel labels display as the group name, and the mapping panel re-derives cue-mapping state on refresh so edits made while it was hidden show correctly. --- src/core/ActorProfileLibrary.cpp | 60 +++++++++++++++++-- src/core/ActorProfileLibrary.h | 15 ++++- src/core/Cue.cpp | 13 +++++ src/core/Cue.h | 5 ++ src/ui/CueEditor.cpp | 26 ++++++--- src/ui/CueEditor.h | 2 + src/ui/CueItemDelegates.cpp | 11 ++-- src/ui/CueItemDelegates.h | 5 +- src/ui/CueListView.cpp | 5 +- src/ui/CueTableModel.cpp | 40 ++++++++----- src/ui/CueTableModel.h | 10 ++++ src/ui/DCAMappingPanel.cpp | 26 +++++---- src/ui/DCAMappingPanel.h | 2 + src/ui/DCAOverrideStrip.cpp | 4 +- tests/CMakeLists.txt | 1 + tests/test_actor_profiles.cpp | 98 ++++++++++++++++++++++++++++++++ tests/test_cue.cpp | 48 ++++++++++++++++ tests/test_cue_table_model.cpp | 98 ++++++++++++++++++++++++++++++++ 18 files changed, 421 insertions(+), 48 deletions(-) diff --git a/src/core/ActorProfileLibrary.cpp b/src/core/ActorProfileLibrary.cpp index 4fd5b21..edbe201 100644 --- a/src/core/ActorProfileLibrary.cpp +++ b/src/core/ActorProfileLibrary.cpp @@ -1,7 +1,10 @@ #include "ActorProfileLibrary.h" +#include "Ensemble.h" #include +#include + namespace OpenMix { ActorProfileLibrary::ActorProfileLibrary(QObject* parent) : QObject(parent) {} @@ -75,14 +78,59 @@ const Actor* ActorProfileLibrary::resolveActor(const QString& text) const { return byRole ? byRole : byName; } -QStringList ActorProfileLibrary::completionCandidates() const { +QList ActorProfileLibrary::resolveChannels(const QString& text, + const EnsembleLibrary* ensembles) const { + const QString needle = text.trimmed(); + if (needle.isEmpty()) + return {}; + + QList channels; + auto collectActive = [&](auto&& matches) { + for (const Actor& a : m_actors) { + if (a.active() && a.channel() >= 1 && matches(a)) + channels.append(a.channel()); + } + }; + + collectActive([&](const Actor& a) { return !a.matchedRole(needle).isEmpty(); }); + + if (channels.isEmpty() && ensembles) { + for (const Ensemble& e : ensembles->ensembles()) { + if (needle.compare(e.name(), Qt::CaseInsensitive) == 0) { + channels = e.channels(); // already sorted/unique/positive + break; + } + } + } + + if (channels.isEmpty()) + collectActive( + [&](const Actor& a) { return needle.compare(a.name(), Qt::CaseInsensitive) == 0; }); + + if (channels.isEmpty()) { + if (const Actor* fallback = resolveActor(needle); fallback && fallback->channel() >= 1) + channels.append(fallback->channel()); + } + + std::sort(channels.begin(), channels.end()); + channels.erase(std::unique(channels.begin(), channels.end()), channels.end()); + return channels; +} + +QStringList ActorProfileLibrary::completionCandidates(const EnsembleLibrary* ensembles) const { QStringList candidates; + auto add = [&candidates](const QString& text) { + if (!text.isEmpty() && !candidates.contains(text, Qt::CaseInsensitive)) + candidates.append(text); + }; for (const Actor& a : m_actors) { - const QStringList texts = a.roles() + QStringList{a.name()}; - for (const QString& text : texts) { - if (!text.isEmpty() && !candidates.contains(text, Qt::CaseInsensitive)) - candidates.append(text); - } + for (const QString& role : a.roles()) + add(role); + add(a.name()); + } + if (ensembles) { + for (const Ensemble& e : ensembles->ensembles()) + add(e.name()); } candidates.sort(Qt::CaseInsensitive); return candidates; diff --git a/src/core/ActorProfileLibrary.h b/src/core/ActorProfileLibrary.h index cd77951..2ece373 100644 --- a/src/core/ActorProfileLibrary.h +++ b/src/core/ActorProfileLibrary.h @@ -11,6 +11,8 @@ namespace OpenMix { +class EnsembleLibrary; + // Owns the show's cast: actors (each assigned to a channel), the set of profile // slots, and which channels are currently on their backup/spare voice. Resolves // the concrete voice to apply for a given channel + slot. Lives on the Show. @@ -38,8 +40,17 @@ class ActorProfileLibrary : public QObject { // actor's roles first, then actor name; among duplicates prefer active, // then lowest order [[nodiscard]] const Actor* resolveActor(const QString& text) const; - // roles + actor names for autocomplete (non-empty, deduplicated, sorted) - [[nodiscard]] QStringList completionCandidates() const; + // resolve free text to the input channels it names, in tiers: + // 1. every ACTIVE actor holding the role (case-insensitive) + // 2. else the identically-named ensemble's member channels + // 3. else every ACTIVE actor with that name + // 4. else the single resolveActor() fallback (covers inactive understudies) + // sorted, deduplicated, excludes unassigned channels (< 1) + [[nodiscard]] QList resolveChannels(const QString& text, + const EnsembleLibrary* ensembles = nullptr) const; + // roles + actor names (+ ensemble names) for autocomplete (non-empty, + // deduplicated, sorted) + [[nodiscard]] QStringList completionCandidates(const EnsembleLibrary* ensembles = nullptr) const; void addActor(const Actor& actor); void updateActor(const QString& id, const Actor& actor); void removeActor(const QString& id); diff --git a/src/core/Cue.cpp b/src/core/Cue.cpp index 58aa70c..f893318 100644 --- a/src/core/Cue.cpp +++ b/src/core/Cue.cpp @@ -199,6 +199,19 @@ void Cue::assignChannelToDCAMapping(int channel, int dca, const DCAMapping* seed setDCAChannelMapping(channelMap); } +void Cue::assignChannelsToDCAMapping(const QList& channels, int dca, + const DCAMapping* seedFrom) { + if (!hasCustomDCAMapping() && seedFrom) + copyDCAMappingFrom(seedFrom); + + QMap> channelMap = dcaChannelMapping(); + for (QList& list : channelMap) // one DCA per channel + for (int ch : channels) + list.removeAll(ch); + channelMap[dca] = channels; // the label declares the DCA's membership + setDCAChannelMapping(channelMap); +} + void Cue::mergeContentFrom(const Cue& other) { // scalar content overwritten from the other cue m_type = other.m_type; diff --git a/src/core/Cue.h b/src/core/Cue.h index 3a02c0e..7f68252 100644 --- a/src/core/Cue.h +++ b/src/core/Cue.h @@ -118,6 +118,11 @@ class Cue { // any other DCA); seeds the custom mapping from the show mapping first if // the cue has none void assignChannelToDCAMapping(int channel, int dca, const class DCAMapping* seedFrom); + // replace this DCA's channel list in the cue's custom mapping with exactly + // `channels` (each also removed from any other DCA); seeds from the show + // mapping first when the cue has none + void assignChannelsToDCAMapping(const QList& channels, int dca, + const class DCAMapping* seedFrom); [[nodiscard]] bool isMacro() const noexcept { return m_type == CueType::Macro; } [[nodiscard]] QStringList childCueIds() const { return m_childCueIds; } diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 0954193..038490b 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -6,6 +6,7 @@ #include "core/ActorProfileLibrary.h" #include "core/Cue.h" #include "core/CueList.h" +#include "core/Ensemble.h" #include "core/FadeCurve.h" #include "core/PlaybackEngine.h" #include "core/Position.h" @@ -36,13 +37,18 @@ namespace OpenMix { +EnsembleLibrary* CueEditor::ensembleLibrary() const { + return (m_app && m_app->show()) ? m_app->show()->ensembleLibrary() : nullptr; +} + CueEditor::CueEditor(Application* app, QWidget* parent) : QWidget(parent), m_app(app) { if (m_app && m_app->show()) m_actorLibrary = m_app->show()->actorProfileLibrary(); m_actorCompletionModel = new QStringListModel(this); if (m_actorLibrary) - m_actorCompletionModel->setStringList(m_actorLibrary->completionCandidates()); + m_actorCompletionModel->setStringList( + m_actorLibrary->completionCandidates(ensembleLibrary())); setupUi(); setEnabled(false); @@ -51,6 +57,9 @@ CueEditor::CueEditor(Application* app, QWidget* parent) : QWidget(parent), m_app connect(m_actorLibrary, &ActorProfileLibrary::changed, this, &CueEditor::onActorLibraryChanged); } + if (EnsembleLibrary* ensembles = ensembleLibrary()) { + connect(ensembles, &EnsembleLibrary::changed, this, &CueEditor::onActorLibraryChanged); + } // reflect the engine's check-mode state (it is engine-wide, not per-cue) if (m_app && m_app->playbackEngine()) { @@ -694,13 +703,15 @@ void CueEditor::onDCALabelCommitted(int dca) { if (!cue || dca < 1 || dca > m_dcaOverrideStrips.size() || !m_actorLibrary) return; - // a matching role/actor name assigns their channel to this DCA for this - // cue; any other text stays a plain scribble label - const Actor* actor = m_actorLibrary->resolveActor(m_dcaOverrideStrips[dca - 1]->labelText()); - if (!actor) + // a matching role/actor/ensemble name assigns all its channels to this DCA + // for this cue (replacing the DCA's previous members); any other text stays + // a plain scribble label + const QList channels = m_actorLibrary->resolveChannels( + m_dcaOverrideStrips[dca - 1]->labelText(), ensembleLibrary()); + if (channels.isEmpty()) return; - cue->assignChannelToDCAMapping(actor->channel(), dca, m_app->show()->dcaMapping()); + cue->assignChannelsToDCAMapping(channels, dca, m_app->show()->dcaMapping()); m_app->show()->cueList()->updateCue(m_currentIndex, *cue); updateDCAAssignInfo(); emit cueModified(); @@ -999,7 +1010,8 @@ void CueEditor::onActorLibraryChanged() { // refresh the autocomplete names and the per-channel table for the cue // currently shown. if (m_actorLibrary) - m_actorCompletionModel->setStringList(m_actorLibrary->completionCandidates()); + m_actorCompletionModel->setStringList( + m_actorLibrary->completionCandidates(ensembleLibrary())); if (m_currentIndex < 0) return; m_updatingUi = true; diff --git a/src/ui/CueEditor.h b/src/ui/CueEditor.h index 5b4dc3c..6817631 100644 --- a/src/ui/CueEditor.h +++ b/src/ui/CueEditor.h @@ -22,6 +22,7 @@ namespace OpenMix { class Application; class ActorProfileLibrary; +class EnsembleLibrary; class Cue; class CollapsibleSection; class DCAOverrideStrip; @@ -73,6 +74,7 @@ class CueEditor : public QWidget { void onDcaCountChanged(int count); private: + [[nodiscard]] EnsembleLibrary* ensembleLibrary() const; void setupUi(); void createDCATargetingSection(); void rebuildDcaTargetChecks(int count); diff --git a/src/ui/CueItemDelegates.cpp b/src/ui/CueItemDelegates.cpp index a800194..53cb620 100644 --- a/src/ui/CueItemDelegates.cpp +++ b/src/ui/CueItemDelegates.cpp @@ -297,15 +297,16 @@ bool CueTypeDelegate::eventFilter(QObject* object, QEvent* event) { return QStyledItemDelegate::eventFilter(object, event); } -DCAAssignDelegate::DCAAssignDelegate(ActorProfileLibrary* library, QObject* parent) - : QStyledItemDelegate(parent), m_library(library) {} +DCAAssignDelegate::DCAAssignDelegate(ActorProfileLibrary* library, EnsembleLibrary* ensembles, + QObject* parent) + : QStyledItemDelegate(parent), m_library(library), m_ensembles(ensembles) {} void DCAAssignDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { - QStyleOptionViewItem opt = option; if (skipPaintForOpenEditor(painter, option, index)) return; + QStyleOptionViewItem opt = option; opt.state &= ~QStyle::State_HasFocus; QVariant bgData = index.data(Qt::BackgroundRole); @@ -330,7 +331,7 @@ QWidget* DCAAssignDelegate::createEditor(QWidget* parent, editor->setPlaceholderText(tr("Role, actor, or label")); if (m_library) { // fresh completer per edit so the candidates are always current - auto* completer = new QCompleter(m_library->completionCandidates(), editor); + auto* completer = new QCompleter(m_library->completionCandidates(m_ensembles), editor); completer->setCaseSensitivity(Qt::CaseInsensitive); completer->setCompletionMode(QCompleter::PopupCompletion); editor->setCompleter(completer); @@ -377,10 +378,10 @@ CueTextDelegate::CueTextDelegate(QObject* parent) : QStyledItemDelegate(parent) void CueTextDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { - QStyleOptionViewItem opt = option; if (skipPaintForOpenEditor(painter, option, index)) return; + QStyleOptionViewItem opt = option; opt.state &= ~QStyle::State_HasFocus; QVariant bgData = index.data(Qt::BackgroundRole); diff --git a/src/ui/CueItemDelegates.h b/src/ui/CueItemDelegates.h index f7ffa71..eef3cb8 100644 --- a/src/ui/CueItemDelegates.h +++ b/src/ui/CueItemDelegates.h @@ -9,6 +9,7 @@ namespace OpenMix { class CueList; class ActorProfileLibrary; +class EnsembleLibrary; class CueNumberDelegate : public QStyledItemDelegate { Q_OBJECT @@ -76,7 +77,8 @@ class DCAAssignDelegate : public QStyledItemDelegate { Q_OBJECT public: - explicit DCAAssignDelegate(ActorProfileLibrary* library, QObject* parent = nullptr); + explicit DCAAssignDelegate(ActorProfileLibrary* library, EnsembleLibrary* ensembles = nullptr, + QObject* parent = nullptr); void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override; @@ -99,6 +101,7 @@ class DCAAssignDelegate : public QStyledItemDelegate { private: ActorProfileLibrary* m_library; + EnsembleLibrary* m_ensembles; mutable QModelIndex m_currentEditIndex; }; diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 63c4600..19c0656 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -67,6 +67,8 @@ void CueListView::setupUi() { // create model & proxy m_model = new CueTableModel(m_app->show()->cueList(), this); m_model->setDcaMapping(m_app->show()->dcaMapping()); + m_model->setActorLibrary(m_app->show()->actorProfileLibrary()); + m_model->setEnsembleLibrary(m_app->show()->ensembleLibrary()); m_model->setDcaCount(m_app->effectiveDcaCount()); connect(m_app, &Application::dcaCountChanged, this, [this](int count) { m_model->setDcaCount(count); }); @@ -200,7 +202,8 @@ void CueListView::setupDelegates() { m_numberDelegate = new CueNumberDelegate(cueList, this); m_typeDelegate = new CueTypeDelegate(this); m_textDelegate = new CueTextDelegate(this); - m_dcaAssignDelegate = new DCAAssignDelegate(m_app->show()->actorProfileLibrary(), this); + m_dcaAssignDelegate = new DCAAssignDelegate(m_app->show()->actorProfileLibrary(), + m_app->show()->ensembleLibrary(), this); // default delegate covers the dynamic per-DCA columns (strips the selection // block so the row's standby/current colour shows through; only the DCA diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 0749620..9cc31ab 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -89,6 +89,11 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { // the actor in that role if (role == Qt::EditRole) return *ov.label; + // multi-channel labels (shared roles, ensembles) read as + // the group name, not one arbitrary member + if (m_actorLibrary && + m_actorLibrary->resolveChannels(*ov.label, m_ensembleLibrary).size() > 1) + return *ov.label; const Actor* actor = m_actorLibrary ? m_actorLibrary->resolveActor(*ov.label) : nullptr; if (actor) { @@ -255,7 +260,8 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { } lines << tr("Members: %1").arg(members.join(", ")); } - lines << tr("Type a role or actor name to auto-assign their channel to this DCA"); + lines << tr("Type a role, actor, or ensemble name to auto-assign " + "their channels to this DCA"); return lines.join("\n"); } @@ -344,6 +350,21 @@ Qt::ItemFlags CueTableModel::flags(const QModelIndex& index) const { return flags; } +void CueTableModel::applyDcaAssignment(Cue& cue, int dca, const QString& text) const { + DCAOverride ov = cue.dcaOverride(dca); + const QString trimmed = text.trimmed(); + ov.label = trimmed.isEmpty() ? std::nullopt : std::optional(trimmed); + cue.setDCAOverride(dca, ov); + + // a resolving label declares the DCA's membership for this cue; scribble + // labels and cleared cells leave the mapping untouched + if (m_actorLibrary && !trimmed.isEmpty()) { + const QList channels = m_actorLibrary->resolveChannels(trimmed, m_ensembleLibrary); + if (!channels.isEmpty()) + cue.assignChannelsToDCAMapping(channels, dca, m_dcaMapping); + } +} + bool CueTableModel::setData(const QModelIndex& index, const QVariant& value, int role) { if (!index.isValid() || role != Qt::EditRole || !m_cueList) { return false; @@ -358,20 +379,13 @@ bool CueTableModel::setData(const QModelIndex& index, const QVariant& value, int Cue cue = m_cueList->at(row); QVariant oldValue; - // DCA assignment cells: set the label override (preserving any mute) and - // resolve a typed role/actor name to a channel assignment — the same - // commit path the assign dialog used + // DCA assignment cells: set the label override and resolve a typed + // role/actor/ensemble name to channel assignments — the same commit path + // the assign dialog used if (dcaSubColumn(col) == 0) { const int dca = dcaOfColumn(col); - DCAOverride ov = cue.dcaOverride(dca); - oldValue = ov.label.value_or(QString()); - const QString text = value.toString().trimmed(); - ov.label = text.isEmpty() ? std::nullopt : std::optional(text); - cue.setDCAOverride(dca, ov); - if (m_actorLibrary && !text.isEmpty()) { - if (const Actor* actor = m_actorLibrary->resolveActor(text)) - cue.assignChannelToDCAMapping(actor->channel(), dca, m_dcaMapping); - } + oldValue = cue.dcaOverride(dca).label.value_or(QString()); + applyDcaAssignment(cue, dca, value.toString()); emit cueEditRequested(row, col, oldValue, value); m_cueList->updateCue(row, cue); return true; diff --git a/src/ui/CueTableModel.h b/src/ui/CueTableModel.h index e31e5b6..6f2682d 100644 --- a/src/ui/CueTableModel.h +++ b/src/ui/CueTableModel.h @@ -9,6 +9,7 @@ class CueList; class Cue; class DCAMapping; class ActorProfileLibrary; +class EnsembleLibrary; class CueTableModel : public QAbstractTableModel { Q_OBJECT @@ -52,6 +53,14 @@ class CueTableModel : public QAbstractTableModel { // cast library: resolves DCA assignment cells to "Actor (Role)" for display // and routes typed labels to channel assignments on edit void setActorLibrary(ActorProfileLibrary* library) { m_actorLibrary = library; } + // ensembles: named channel groups resolvable from DCA assignment cells + void setEnsembleLibrary(EnsembleLibrary* library) { m_ensembleLibrary = library; } + + // apply one DCA-assignment edit to a cue copy: set/clear the label override + // (preserving any mute) and resolve a typed role/actor/ensemble name to + // channel assignments in the cue's DCA mapping — the single commit path + // for inline typing and TSV paste + void applyDcaAssignment(Cue& cue, int dca, const QString& text) const; explicit CueTableModel(CueList* cueList, QObject* parent = nullptr); @@ -107,6 +116,7 @@ class CueTableModel : public QAbstractTableModel { int m_dcaCount = 8; DCAMapping* m_dcaMapping = nullptr; ActorProfileLibrary* m_actorLibrary = nullptr; + EnsembleLibrary* m_ensembleLibrary = nullptr; static const QString s_mimeType; }; diff --git a/src/ui/DCAMappingPanel.cpp b/src/ui/DCAMappingPanel.cpp index 6cbc03b..1cc8fcd 100644 --- a/src/ui/DCAMappingPanel.cpp +++ b/src/ui/DCAMappingPanel.cpp @@ -637,9 +637,23 @@ void DCAMappingPanel::refresh() { if (m_app && m_app->show()) { m_mapping = m_app->show()->dcaMapping(); } + // a hidden panel misses cue edits (the cueUpdated hook is gated on + // visibility), so a full refresh re-derives the cue-mapping state instead + // of trusting what was last shown + syncCueMappingState(); + updateContextHeader(); updateDCAOptions(); } +void DCAMappingPanel::syncCueMappingState() { + m_showingCueMapping = m_currentCue && m_currentCue->hasCustomDCAMapping(); + if (m_currentCue) { + m_useCueMappingCheck->blockSignals(true); + m_useCueMappingCheck->setChecked(m_showingCueMapping); + m_useCueMappingCheck->blockSignals(false); + } +} + void DCAMappingPanel::syncFromMixer() { if (!m_app || !m_app->mixer() || !m_mapping) { QMessageBox::warning(m_app ? m_app->mainWindow() : nullptr, tr("Sync from Mixer"), @@ -887,17 +901,7 @@ void DCAMappingPanel::onMixerDisconnected() { void DCAMappingPanel::setCurrentCue(Cue* cue) { m_currentCue = cue; - - if (cue) { - // check if cue has custom mapping - m_showingCueMapping = cue->hasCustomDCAMapping(); - m_useCueMappingCheck->blockSignals(true); - m_useCueMappingCheck->setChecked(m_showingCueMapping); - m_useCueMappingCheck->blockSignals(false); - } else { - m_showingCueMapping = false; - } - + syncCueMappingState(); updateContextHeader(); populateFromMapping(); } diff --git a/src/ui/DCAMappingPanel.h b/src/ui/DCAMappingPanel.h index 53d2f18..1318963 100644 --- a/src/ui/DCAMappingPanel.h +++ b/src/ui/DCAMappingPanel.h @@ -68,6 +68,8 @@ class DCAMappingPanel : public QWidget { bool eventFilter(QObject* obj, QEvent* event) override; private: + // re-derive m_showingCueMapping + checkbox from the current cue's state + void syncCueMappingState(); void setupUi(); void createChannelSection(); void createBusSection(); diff --git a/src/ui/DCAOverrideStrip.cpp b/src/ui/DCAOverrideStrip.cpp index ecbcbab..1ff00db 100644 --- a/src/ui/DCAOverrideStrip.cpp +++ b/src/ui/DCAOverrideStrip.cpp @@ -32,8 +32,8 @@ DCAOverrideStrip::DCAOverrideStrip(int dcaNumber, QAbstractItemModel* completion m_labelEdit = new QLineEdit(this); m_labelEdit->setPlaceholderText(tr("(no label change)")); m_labelEdit->setToolTip( - tr("Typing a role or actor name assigns their channel to this DCA for this cue; " - "empty leaves the console label untouched")); + tr("Typing a role, actor, or ensemble name assigns their channels to this DCA " + "for this cue; empty leaves the console label untouched")); m_labelEdit->setClearButtonEnabled(true); m_labelEdit->setMaxLength(32); if (completionModel) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 41c213b..9a9c50a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -143,6 +143,7 @@ add_executable(test_cue_table_model ${CMAKE_SOURCE_DIR}/src/core/Actor.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfile.cpp ${CMAKE_SOURCE_DIR}/src/core/ActorProfileLibrary.cpp + ${CMAKE_SOURCE_DIR}/src/core/Ensemble.cpp ) target_link_libraries(test_cue_table_model PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Test) target_include_directories(test_cue_table_model PRIVATE ${CMAKE_SOURCE_DIR}/src) diff --git a/tests/test_actor_profiles.cpp b/tests/test_actor_profiles.cpp index 7987b04..22ab67e 100644 --- a/tests/test_actor_profiles.cpp +++ b/tests/test_actor_profiles.cpp @@ -2,6 +2,7 @@ #include "core/ActorProfile.h" #include "core/ActorProfileLibrary.h" #include "core/Cue.h" +#include "core/Ensemble.h" #include "core/Show.h" #include #include @@ -252,6 +253,103 @@ class TestActorProfiles : public QObject { QVERIFY(lib.resolveActor(" ") == nullptr); } + void library_resolveChannels_roleMatchesAllActiveActors() { + ActorProfileLibrary lib; + auto addWithRole = [&lib](const QString& name, int ch, bool active) { + Actor a(name, ch); + a.setRoles({"Nuns"}); + a.setActive(active); + lib.addActor(a); + }; + addWithRole("Alice", 5, true); + addWithRole("Beth", 7, true); + addWithRole("Cara", 3, true); + addWithRole("Dora", 9, false); // inactive cover excluded + + QCOMPARE(lib.resolveChannels("nuns"), QList({3, 5, 7})); + } + + void library_resolveChannels_ensembleName_returnsMemberChannels() { + ActorProfileLibrary lib; + lib.addActor(Actor("Alice", 5)); + EnsembleLibrary ensembles; + Ensemble band("Band"); + band.setChannels({10, 11}); + ensembles.addEnsemble(band); + + QCOMPARE(lib.resolveChannels("band", &ensembles), QList({10, 11})); + // without the ensemble library the name resolves to nothing + QCOMPARE(lib.resolveChannels("band"), QList()); + } + + void library_resolveChannels_rolePrecedesEnsemble() { + ActorProfileLibrary lib; + Actor kid("Kid Lead", 2); + kid.setRoles({"Kids"}); + lib.addActor(kid); + EnsembleLibrary ensembles; + Ensemble kids("Kids"); + kids.setChannels({20, 21}); + ensembles.addEnsemble(kids); + + QCOMPARE(lib.resolveChannels("Kids", &ensembles), QList({2})); + } + + void library_resolveChannels_ensemblePrecedesActorName() { + ActorProfileLibrary lib; + lib.addActor(Actor("Band", 4)); // actor literally named like the group + EnsembleLibrary ensembles; + Ensemble band("Band"); + band.setChannels({15, 16}); + ensembles.addEnsemble(band); + + QCOMPARE(lib.resolveChannels("Band", &ensembles), QList({15, 16})); + } + + void library_resolveChannels_allInactive_fallsBackToSingleBest() { + ActorProfileLibrary lib; + Actor lead("Lead", 3); + lead.setRoles({"Evan"}); + lead.setOrder(1); + lead.setActive(false); + Actor cover("Cover", 4); + cover.setRoles({"Evan"}); + cover.setOrder(2); + cover.setActive(false); + lib.addActor(cover); + lib.addActor(lead); + + // resolveActor's preference (lowest order among equally-inactive) holds + QCOMPARE(lib.resolveChannels("Evan"), QList({3})); + } + + void library_resolveChannels_skipsUnassignedChannel_andDedups() { + ActorProfileLibrary lib; + Actor unset("Unset", 0); // no channel assigned yet + unset.setRoles({"Chorus"}); + lib.addActor(unset); + Actor a("Alto", 6); + a.setRoles({"Chorus"}); + lib.addActor(a); + Actor b("Bass", 6); // shares the channel + b.setRoles({"Chorus"}); + lib.addActor(b); + + QCOMPARE(lib.resolveChannels("Chorus"), QList({6})); + QCOMPARE(lib.resolveChannels(""), QList()); + QCOMPARE(lib.resolveChannels(" "), QList()); + } + + void library_completionCandidates_includesEnsembleNames() { + ActorProfileLibrary lib; + lib.addActor(Actor("Alice", 5)); + EnsembleLibrary ensembles; + ensembles.addEnsemble(Ensemble("Band")); + ensembles.addEnsemble(Ensemble("alice")); // ci-duplicate of the actor + + QCOMPARE(lib.completionCandidates(&ensembles), QStringList({"Alice", "Band"})); + } + void library_completionCandidates_dedupSkipsEmpty() { ActorProfileLibrary lib; Actor alice("Alice", 5); diff --git a/tests/test_cue.cpp b/tests/test_cue.cpp index b8fbe50..c262cb7 100644 --- a/tests/test_cue.cpp +++ b/tests/test_cue.cpp @@ -318,6 +318,54 @@ class TestCue : public QObject { Cue r = Cue::fromJson(cue.toJson()); QCOMPARE(r.dcaChannelMapping().value(4), QList({7})); } + + void testAssignChannelsToDCAMapping_seedsAndReplacesTargetDca() { + DCAMapping show; + show.assignChannelToDCA(1, 1); + show.assignChannelToDCA(2, 1); + show.assignChannelToDCA(9, 2); + + Cue cue(1.0, "Opening"); + cue.assignChannelsToDCAMapping({5, 6}, 1, &show); + + QVERIFY(cue.hasCustomDCAMapping()); + // the label declares DCA 1's membership: seeded {1,2} replaced by {5,6} + QCOMPARE(cue.dcaChannelMapping().value(1), QList({5, 6})); + QCOMPARE(cue.dcaChannelMapping().value(2), QList({9})); + // show mapping untouched + QCOMPARE(show.channelsForDCA(1), QList({1, 2})); + } + + void testAssignChannelsToDCAMapping_movesChannelsOffOtherDCAs() { + Cue cue; + QMap> mapping; + mapping[1] = {5}; + mapping[2] = {6, 7}; + cue.setDCAChannelMapping(mapping); + + cue.assignChannelsToDCAMapping({5, 6}, 3, nullptr); + + QVERIFY(cue.dcaChannelMapping().value(1).isEmpty()); + QCOMPARE(cue.dcaChannelMapping().value(2), QList({7})); + QCOMPARE(cue.dcaChannelMapping().value(3), QList({5, 6})); + } + + void testAssignChannelsToDCAMapping_retypeSwaps() { + Cue cue; + cue.assignChannelsToDCAMapping({5, 6}, 1, nullptr); + cue.assignChannelsToDCAMapping({8}, 1, nullptr); + + QCOMPARE(cue.dcaChannelMapping().value(1), QList({8})); + for (const QList& channels : cue.dcaChannelMapping()) { + QVERIFY(!channels.contains(5)); + QVERIFY(!channels.contains(6)); + } + + // multi-channel list round-trips through JSON + cue.assignChannelsToDCAMapping({3, 4}, 2, nullptr); + Cue r = Cue::fromJson(cue.toJson()); + QCOMPARE(r.dcaChannelMapping().value(2), QList({3, 4})); + } }; QTEST_MAIN(TestCue) diff --git a/tests/test_cue_table_model.cpp b/tests/test_cue_table_model.cpp index dccf023..f5f36d4 100644 --- a/tests/test_cue_table_model.cpp +++ b/tests/test_cue_table_model.cpp @@ -3,6 +3,7 @@ #include "core/Cue.h" #include "core/CueList.h" #include "core/DCAMapping.h" +#include "core/Ensemble.h" #include "ui/CueTableModel.h" #include #include @@ -132,6 +133,103 @@ class TestCueTableModel : public QObject { QCOMPARE(list.at(0).dcaOverride(1).mute, std::optional(true)); } + void setData_roleWithMultipleActiveActors_assignsAllChannels() { + CueList list; + list.addCue(Cue(1.0, "One")); + DCAMapping mapping; + + ActorProfileLibrary library; + auto addWithRole = [&library](const QString& name, int ch, bool active) { + Actor a(name, ch); + a.setRoles({"Nuns"}); + a.setActive(active); + library.addActor(a); + }; + addWithRole("Alice", 5, true); + addWithRole("Beth", 6, true); + addWithRole("Cara", 7, false); // inactive cover excluded + + CueTableModel model(&list); + model.setDcaMapping(&mapping); + model.setActorLibrary(&library); + + QVERIFY(model.setData(model.index(0, dcaCol(model, 1)), "Nuns", Qt::EditRole)); + QCOMPARE(list.at(0).dcaChannelMapping().value(1), QList({5, 6})); + } + + void setData_ensembleName_assignsMemberChannels() { + CueList list; + list.addCue(Cue(1.0, "One")); + DCAMapping mapping; + ActorProfileLibrary library; + EnsembleLibrary ensembles; + Ensemble band("Band"); + band.setChannels({10, 11}); + ensembles.addEnsemble(band); + + CueTableModel model(&list); + model.setDcaMapping(&mapping); + model.setActorLibrary(&library); + model.setEnsembleLibrary(&ensembles); + + QVERIFY(model.setData(model.index(0, dcaCol(model, 2)), "Band", Qt::EditRole)); + QCOMPARE(list.at(0).dcaChannelMapping().value(2), QList({10, 11})); + } + + void setData_retype_swapsDcaChannels() { + CueList list; + list.addCue(Cue(1.0, "One")); + DCAMapping mapping; + + ActorProfileLibrary library; + Actor alice("Alice", 5); + alice.setRoles({"Cosette"}); + library.addActor(alice); + Actor bob("Bob", 6); + bob.setRoles({"Marius"}); + library.addActor(bob); + + CueTableModel model(&list); + model.setDcaMapping(&mapping); + model.setActorLibrary(&library); + + const QModelIndex cell = model.index(0, dcaCol(model, 1)); + QVERIFY(model.setData(cell, "Cosette", Qt::EditRole)); + QVERIFY(model.setData(cell, "Marius", Qt::EditRole)); + + // retyping swaps the DCA's membership; Alice's channel is released + QCOMPARE(list.at(0).dcaChannelMapping().value(1), QList({6})); + + // a later scribble label leaves the mapping untouched + QVERIFY(model.setData(cell, "walla!", Qt::EditRole)); + QCOMPARE(list.at(0).dcaChannelMapping().value(1), QList({6})); + QCOMPARE(list.at(0).dcaOverride(1).label, std::optional("walla!")); + } + + void display_multiChannelLabel_staysVerbatim() { + CueList list; + Cue cue(1.0, "One"); + DCAOverride ov; + ov.label = "Nuns"; + cue.setDCAOverride(1, ov); + list.addCue(cue); + + ActorProfileLibrary library; + Actor alice("Alice", 5); + alice.setRoles({"Nuns"}); + library.addActor(alice); + Actor beth("Beth", 6); + beth.setRoles({"Nuns"}); + library.addActor(beth); + + CueTableModel model(&list); + model.setActorLibrary(&library); + + // group labels read as the group, not one arbitrary member + QCOMPARE(model.data(model.index(0, dcaCol(model, 1)), Qt::DisplayRole).toString(), + QString("Nuns")); + } + void display_resolvesActorInRole_editReturnsRawLabel() { CueList list; Cue cue(1.0, "One"); From faa51f6df74c332ff3a56c4b8ff92f5c00689d68 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 5 Jul 2026 14:52:03 -0400 Subject: [PATCH 5/7] feat: per-DCA active toggle reserves DCAs from control Operators keep some console DCAs hands-on (band, FX); the app assuming it owns every DCA overwrote them on fire. An Active checkbox per DCA in the mapping panel now scopes what the app controls: inactive DCAs drop out of the cue list, feedback panel, and cue editor, grey out in the mapping combos (existing assignments preserved), and are never written during playback - override sends, mapped channel/bus snapshot params, captured /dca params, and the stop-and-apply recall are all gated. Stored inverse (inactiveDcas) and per show, so old files and newly available DCAs default to active. Show version 1.10. --- src/app/Application.cpp | 13 ++++++ src/app/Application.h | 6 +++ src/core/PlaybackEngine.cpp | 45 +++++++++++++++--- src/core/PlaybackEngine.h | 5 ++ src/core/Show.cpp | 39 +++++++++++++++- src/core/Show.h | 12 +++++ src/ui/CueEditor.cpp | 18 +++++++- src/ui/CueEditor.h | 1 + src/ui/CueListView.cpp | 17 +++++-- src/ui/DCAMappingPanel.cpp | 79 ++++++++++++++++++++++++++------ src/ui/DCAMappingPanel.h | 2 + src/ui/MixerFeedbackPanel.cpp | 13 ++++++ src/ui/MixerFeedbackPanel.h | 1 + tests/test_actor_profiles.cpp | 2 +- tests/test_ensembles.cpp | 2 +- tests/test_playback_profiles.cpp | 63 +++++++++++++++++++++++++ tests/test_show.cpp | 36 +++++++++++++++ 17 files changed, 324 insertions(+), 30 deletions(-) diff --git a/src/app/Application.cpp b/src/app/Application.cpp index e4b2a91..d1ed595 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -113,6 +113,7 @@ void Application::initialize() { m_playbackEngine->setChannelGangs(m_show->channelGangs()); m_playbackEngine->setDimDcaFaders(m_show->dimDcaFaders()); m_playbackEngine->setMuteDcaUnassign(m_show->muteDcaUnassign()); + m_playbackEngine->setInactiveDcas(m_show->inactiveDcas()); // re-seed gangs + console-behavior toggles whenever a project is loaded // (fromJson re-emits nameChanged) @@ -120,6 +121,14 @@ void Application::initialize() { m_playbackEngine->setChannelGangs(m_show->channelGangs()); m_playbackEngine->setDimDcaFaders(m_show->dimDcaFaders()); m_playbackEngine->setMuteDcaUnassign(m_show->muteDcaUnassign()); + m_playbackEngine->setInactiveDcas(m_show->inactiveDcas()); + emit activeDcasChanged(); + }); + + // live edits from the mapping panel + connect(m_show, &Show::activeDcasChanged, this, [this]() { + m_playbackEngine->setInactiveDcas(m_show->inactiveDcas()); + emit activeDcasChanged(); }); // effective DCA count follows the selected console type, project loads, @@ -399,6 +408,10 @@ MixerCapabilities Application::effectiveCapabilities() const { return MixerCapabilities::forProtocolId(m_show->mixerConfig().type); } +QSet Application::inactiveDcas() const { return m_show->inactiveDcas(); } + +bool Application::isDcaActive(int dca) const { return m_show->isDcaActive(dca); } + void Application::refreshDcaCount() { const int count = effectiveDcaCount(); if (count == m_lastDcaCount) diff --git a/src/app/Application.h b/src/app/Application.h index b7d8847..7767730 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -3,6 +3,7 @@ #include "protocol/MixerCapabilities.h" #include #include +#include #include namespace OpenMix { @@ -97,6 +98,10 @@ class Application : public QObject { [[nodiscard]] MixerCapabilities effectiveCapabilities() const; [[nodiscard]] int effectiveDcaCount() const { return effectiveCapabilities().dcaCount; } + // the show's active-DCA subset (see Show::inactiveDcas) + [[nodiscard]] QSet inactiveDcas() const; + [[nodiscard]] bool isDcaActive(int dca) const; + // main window void setMainWindow(MainWindow* window); [[nodiscard]] MainWindow* mainWindow(); @@ -117,6 +122,7 @@ class Application : public QObject { void mixerDisconnected(); void recordFadersActiveChanged(bool active); void dcaCountChanged(int count); + void activeDcasChanged(); private: void setupMixerConnection(const QString& type, const QString& host, int port); diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index a7757be..4538bbe 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -288,8 +288,9 @@ void PlaybackEngine::executeCueInternal(const Cue& cue) { ~DepthGuard() { --depth; } } depthGuard{m_fireDepth}; - // resolve which DCAs to target - QSet targetDCAs = cue.targetsAllDCAs() ? allDCAs() : cue.targetedDCAs(); + // resolve which DCAs to target; inactive DCAs are reserved for manual + // console control and never written to + QSet targetDCAs = (cue.targetsAllDCAs() ? allDCAs() : cue.targetedDCAs()) - m_inactiveDcas; switch (cue.type()) { case CueType::Snapshot: { @@ -353,6 +354,10 @@ void PlaybackEngine::applyDCAOverrides(const Cue& cue, const QSet& targetDC for (int dca : targetDCAs) { if (dca < 1 || dca > caps.dcaCount) continue; + // callers already subtract inactive DCAs; keep the guard local too so + // no future call site can write to a reserved DCA + if (m_inactiveDcas.contains(dca)) + continue; DCAOverride override = cue.dcaOverride(dca); @@ -532,7 +537,8 @@ void PlaybackEngine::verifyCue(int index, const Cue& cue) { return; // verify the generic snapshot params (sent instantly via recallSnapshot). - const QSet targetDCAs = cue.targetsAllDCAs() ? allDCAs() : cue.targetedDCAs(); + const QSet targetDCAs = + (cue.targetsAllDCAs() ? allDCAs() : cue.targetedDCAs()) - m_inactiveDcas; const QJsonObject params = filterParametersForDCAs(cue, targetDCAs); // only numeric params (faders/levels/toggles) are verifiable by value; @@ -597,8 +603,20 @@ QList PlaybackEngine::getBusesForDCA(const Cue& cue, int dca) const { QJsonObject PlaybackEngine::filterParametersForDCAs(const Cue& cue, const QSet& targetDCAs) const { + // any captured /dca/N/... param of an inactive DCA must never reach the + // console, regardless of mapping state + static QRegularExpression dcaAnyRegex("^/dca/(\\d+)/"); + auto inactiveDcaParam = [this](const QString& path) { + const QRegularExpressionMatch m = dcaAnyRegex.match(path); + return m.hasMatch() && m_inactiveDcas.contains(m.captured(1).toInt()); + }; + + // without a mapping there is nothing to relate channels to DCAs, so no + // channel filtering happens. An empty target set with a mapping is real + // (e.g. every targeted DCA is inactive) and falls through to the filter + // below, which then drops every mapped channel/bus. bool hasMapping = cue.hasCustomDCAMapping() || m_dcaMapping; - if (!hasMapping || targetDCAs.isEmpty()) { + if (!hasMapping) { QJsonObject filtered; static QRegularExpression dcaFaderRegex("^/dca/\\d+/fader$"); @@ -606,7 +624,7 @@ QJsonObject PlaybackEngine::filterParametersForDCAs(const Cue& cue, // holds a back-pointer to the object, so iterating the temporary dangles. const QJsonObject params = cue.parameters(); for (auto it = params.begin(); it != params.end(); ++it) { - if (!dcaFaderRegex.match(it.key()).hasMatch()) { + if (!dcaFaderRegex.match(it.key()).hasMatch() && !inactiveDcaParam(it.key())) { filtered[it.key()] = it.value(); } } @@ -639,7 +657,7 @@ QJsonObject PlaybackEngine::filterParametersForDCAs(const Cue& cue, for (auto it = params.begin(); it != params.end(); ++it) { const QString& path = it.key(); - if (dcaFaderRegex.match(path).hasMatch()) { + if (dcaFaderRegex.match(path).hasMatch() || inactiveDcaParam(path)) { continue; } @@ -764,7 +782,20 @@ void PlaybackEngine::executeStopCue(const Cue& cue) { case StopBehavior::StopAndApply: if (m_mixer && !cue.parameters().isEmpty()) { - m_mixer->recallSnapshot(cue); + // this path recalls the raw snapshot; strip captured params of + // inactive DCAs so reserved DCAs stay untouched + static QRegularExpression dcaAnyRegex("^/dca/(\\d+)/"); + QJsonObject params; + const QJsonObject original = cue.parameters(); + for (auto it = original.begin(); it != original.end(); ++it) { + const QRegularExpressionMatch m = dcaAnyRegex.match(it.key()); + if (m.hasMatch() && m_inactiveDcas.contains(m.captured(1).toInt())) + continue; + params[it.key()] = it.value(); + } + Cue filteredCue = cue; + filteredCue.setParameters(params); + m_mixer->recallSnapshot(filteredCue); } setState(PlaybackState::Running); emit cueCompleted(m_currentIndex); diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index 6294335..f5a43c0 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -71,6 +71,10 @@ class PlaybackEngine : public QObject { // DCA count assumed while no mixer is connected (from the configured console) void setFallbackDcaCount(int count) { m_fallbackDcaCount = count; } + // DCAs OpenMix must not write to on the console (reserved for manual + // control; mirrors Show::inactiveDcas) + void setInactiveDcas(const QSet& dcas) { m_inactiveDcas = dcas; } + // channel mute state driven by MIDI mute buttons (console-style mute toggle) [[nodiscard]] bool isChannelMuted(int channel) const { return m_channelMutes.value(channel); } @@ -182,6 +186,7 @@ class PlaybackEngine : public QObject { bool m_dimDcaFaders = false; // dim a DCA's fader when a cue mutes it bool m_muteDcaUnassign = false; // unassign a DCA's channels when muted int m_fallbackDcaCount = 8; // disconnected-state DCA count + QSet m_inactiveDcas; // DCAs reserved for manual console control QHash m_channelMutes; // channel -> mute state (MIDI mute buttons) PlaybackState m_state = PlaybackState::Stopped; int m_currentIndex = -1; diff --git a/src/core/Show.cpp b/src/core/Show.cpp index 481daf2..435c103 100644 --- a/src/core/Show.cpp +++ b/src/core/Show.cpp @@ -1,6 +1,8 @@ #include "Show.h" #include +#include + namespace OpenMix { QJsonObject MixerConfig::toJson() const { @@ -103,6 +105,26 @@ void Show::connectCueZeroSignals() { connect(&m_cueZero, &CueZero::changed, this, &Show::checkModifiedState); } +void Show::setDcaActive(int dca, bool active) { + const bool wasActive = !m_inactiveDcas.contains(dca); + if (active == wasActive) + return; + if (active) + m_inactiveDcas.remove(dca); + else + m_inactiveDcas.insert(dca); + emit activeDcasChanged(); + checkModifiedState(); +} + +void Show::setInactiveDcas(const QSet& dcas) { + if (dcas == m_inactiveDcas) + return; + m_inactiveDcas = dcas; + emit activeDcasChanged(); + checkModifiedState(); +} + void Show::setChannelGangs(const QList>& gangs) { m_channelGangs = gangs; // keep per-gang metadata aligned by index, preserving existing entries @@ -147,6 +169,7 @@ void Show::newShow() { m_selectOnSpill = false; m_muteDcaUnassign = false; m_suppressBackupSwitch = false; + m_inactiveDcas.clear(); m_channelGangMeta.clear(); m_mixerConfig = MixerConfig(); m_mixerConfig.type = "x32"; @@ -168,7 +191,7 @@ void Show::newShow() { QJsonObject Show::toJson() const { QJsonObject json; - json["version"] = "1.9"; // 1.9: multiple roles per actor + json["version"] = "1.10"; // 1.10: inactive-DCA set json["name"] = m_name; json["author"] = m_author; json["designer"] = m_designer; @@ -179,6 +202,15 @@ QJsonObject Show::toJson() const { json["selectOnSpill"] = m_selectOnSpill; json["muteDcaUnassign"] = m_muteDcaUnassign; json["suppressBackupSwitch"] = m_suppressBackupSwitch; + + if (!m_inactiveDcas.isEmpty()) { + QList inactive = m_inactiveDcas.values(); + std::sort(inactive.begin(), inactive.end()); // stable file diffs + QJsonArray inactiveArr; + for (int dca : inactive) + inactiveArr.append(dca); + json["inactiveDcas"] = inactiveArr; + } json["cues"] = m_cueList.toJson(); json["dcaMapping"] = m_dcaMapping.toJson(); json["actors"] = m_actorProfileLibrary.toJson(); @@ -230,6 +262,11 @@ void Show::fromJson(const QJsonObject& json) { m_muteDcaUnassign = json["muteDcaUnassign"].toBool(false); m_suppressBackupSwitch = json["suppressBackupSwitch"].toBool(false); + // inactive-DCA set (added in show version 1.10); missing key = all active + m_inactiveDcas.clear(); + for (const QJsonValue& val : json["inactiveDcas"].toArray()) + m_inactiveDcas.insert(val.toInt()); + if (json.contains("dcaMapping")) { m_dcaMapping.loadFromJson(json["dcaMapping"].toObject()); } else { diff --git a/src/core/Show.h b/src/core/Show.h index 1fe121f..465eb51 100644 --- a/src/core/Show.h +++ b/src/core/Show.h @@ -13,6 +13,7 @@ #include #include #include +#include #include namespace OpenMix { @@ -118,6 +119,15 @@ class Show : public QObject { [[nodiscard]] bool suppressBackupSwitch() const noexcept { return m_suppressBackupSwitch; } void setSuppressBackupSwitch(bool on) { m_suppressBackupSwitch = on; checkModifiedState(); } + // DCAs OpenMix does not control: hidden from the cue list, greyed in the + // mapping panel, and never written to on the console during playback. + // Stored inverse so old shows (no key) and newly-available DCAs default + // to active. + [[nodiscard]] QSet inactiveDcas() const { return m_inactiveDcas; } + [[nodiscard]] bool isDcaActive(int dca) const { return !m_inactiveDcas.contains(dca); } + void setDcaActive(int dca, bool active); + void setInactiveDcas(const QSet& dcas); + QJsonObject toJson() const; void fromJson(const QJsonObject& json); @@ -127,6 +137,7 @@ class Show : public QObject { void nameChanged(const QString& name); void modifiedChanged(bool modified); void mixerConfigChanged(); + void activeDcasChanged(); private: void connectCueListSignals(); @@ -150,6 +161,7 @@ class Show : public QObject { bool m_selectOnSpill = false; bool m_muteDcaUnassign = false; bool m_suppressBackupSwitch = false; + QSet m_inactiveDcas; DCAMapping m_dcaMapping; ActorProfileLibrary m_actorProfileLibrary; diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 038490b..9d863e9 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -69,8 +69,11 @@ CueEditor::CueEditor(Application* app, QWidget* parent) : QWidget(parent), m_app connect(engine, &PlaybackEngine::checkModeChanged, this, &CueEditor::onEngineCheckModeChanged); } - if (m_app) + if (m_app) { connect(m_app, &Application::dcaCountChanged, this, &CueEditor::onDcaCountChanged); + connect(m_app, &Application::activeDcasChanged, this, + &CueEditor::applyActiveDcaVisibility); + } updateGangsUI(); } @@ -397,6 +400,7 @@ void CueEditor::rebuildDcaTargetChecks(int count) { onTargetedDCAsChanged(); }); } + applyActiveDcaVisibility(); } void CueEditor::rebuildDcaOverrideStrips(int count) { @@ -413,6 +417,18 @@ void CueEditor::rebuildDcaOverrideStrips(int count) { m_dcaOverridesContentLayout->insertWidget(m_dcaOverridesContentLayout->count() - 1, strip); m_dcaOverrideStrips.append(strip); } + applyActiveDcaVisibility(); +} + +// inactive DCAs disappear from targeting and overrides; hidden checks keep +// their state (cue data is preserved, playback gating makes it inert) +void CueEditor::applyActiveDcaVisibility() { + if (!m_app) + return; + for (int i = 0; i < m_dcaTargetChecks.size(); ++i) + m_dcaTargetChecks[i]->setVisible(m_app->isDcaActive(i + 1)); + for (int i = 0; i < m_dcaOverrideStrips.size(); ++i) + m_dcaOverrideStrips[i]->setVisible(m_app->isDcaActive(i + 1)); } void CueEditor::onDcaCountChanged(int count) { diff --git a/src/ui/CueEditor.h b/src/ui/CueEditor.h index 6817631..4bde636 100644 --- a/src/ui/CueEditor.h +++ b/src/ui/CueEditor.h @@ -75,6 +75,7 @@ class CueEditor : public QWidget { private: [[nodiscard]] EnsembleLibrary* ensembleLibrary() const; + void applyActiveDcaVisibility(); void setupUi(); void createDCATargetingSection(); void rebuildDcaTargetChecks(int count); diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 19c0656..3364d3b 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -110,6 +110,10 @@ void CueListView::setupUi() { applyDcaSubColumnVisibility(); connect(m_proxyModel, &QAbstractItemModel::modelReset, this, &CueListView::applyDcaSubColumnVisibility); + connect(m_app, &Application::activeDcasChanged, this, [this]() { + applyDcaSubColumnVisibility(); + scheduleColumnRelayout(); + }); // columns auto-fit their contents; leftover space is shared, and columns only // shrink below their natural width when the viewport can't fit them all @@ -547,19 +551,22 @@ void CueListView::setDcaSubColumnsVisible(int sub, bool visible) { m_dcaFxColsVisible = visible; else if (sub == 2) m_dcaPosColsVisible = visible; - for (int c = CueTableModel::ColCount; c < m_model->columnCount(); ++c) - if (m_model->dcaSubColumn(c) == sub) - m_tableView->setColumnHidden(c, !visible); + applyDcaSubColumnVisibility(); scheduleColumnRelayout(); } +// single visibility authority for the dynamic DCA columns: an inactive DCA's +// whole triplet is hidden, and fx/pos additionally follow their menu toggles void CueListView::applyDcaSubColumnVisibility() { for (int c = CueTableModel::ColCount; c < m_model->columnCount(); ++c) { const int sub = m_model->dcaSubColumn(c); + const bool active = m_app->isDcaActive(m_model->dcaOfColumn(c)); + bool visible = active; if (sub == 1) - m_tableView->setColumnHidden(c, !m_dcaFxColsVisible); + visible = active && m_dcaFxColsVisible; else if (sub == 2) - m_tableView->setColumnHidden(c, !m_dcaPosColsVisible); + visible = active && m_dcaPosColsVisible; + m_tableView->setColumnHidden(c, !visible); } } diff --git a/src/ui/DCAMappingPanel.cpp b/src/ui/DCAMappingPanel.cpp index 1cc8fcd..97d499d 100644 --- a/src/ui/DCAMappingPanel.cpp +++ b/src/ui/DCAMappingPanel.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include @@ -97,6 +98,13 @@ DCAMappingPanel::DCAMappingPanel(Application* app, QWidget* parent) &DCAMappingPanel::onMixerDisconnected); // console-type selection changes the DCA/channel/bus counts shown here connect(m_app, &Application::dcaCountChanged, this, &DCAMappingPanel::refresh); + // light repaint, not refresh(): a full rebuild would tear down the + // checkbox that is mid-toggle + connect(m_app, &Application::activeDcasChanged, this, [this]() { + updateActiveDcaChecks(); + updateComboItemStates(); + updateDcaOverview(); + }); m_mapping = m_app->show()->dcaMapping(); if (m_mapping) { @@ -288,26 +296,55 @@ void DCAMappingPanel::createDcaOverviewSection() { } m_dcaOverviewTitles.clear(); m_dcaOverviewMembers.clear(); + m_dcaActiveChecks.clear(); for (int d = 1; d <= m_dcaCount; ++d) { int row = (d - 1) % 4; - int col = ((d - 1) / 4) * 2; + int col = ((d - 1) / 4) * 3; + + QCheckBox* active = new QCheckBox(m_dcaOverviewGroup); + active->setToolTip(tr("Uncheck to reserve this DCA for manual console control: " + "OpenMix hides it from the cue list and never writes to it " + "during playback")); + connect(active, &QCheckBox::toggled, this, [this, d](bool on) { + if (!m_updatingUi && m_app && m_app->show()) + m_app->show()->setDcaActive(d, on); + }); + m_dcaOverviewLayout->addWidget(active, row, col); + m_dcaActiveChecks.append(active); QLabel* title = new QLabel(tr("DCA %1").arg(d), m_dcaOverviewGroup); title->setStyleSheet(QString("font-weight: bold; color: %1;").arg(Theme::Colors::TextPrimary)); title->setMinimumWidth(120); - m_dcaOverviewLayout->addWidget(title, row, col); + m_dcaOverviewLayout->addWidget(title, row, col + 1); m_dcaOverviewTitles.append(title); QLabel* members = new QLabel(m_dcaOverviewGroup); members->setWordWrap(true); members->setStyleSheet(QString("color: %1;").arg(Theme::Colors::TextSecondary)); - m_dcaOverviewLayout->addWidget(members, row, col + 1); - m_dcaOverviewLayout->setColumnStretch(col + 1, 1); + m_dcaOverviewLayout->addWidget(members, row, col + 2); + m_dcaOverviewLayout->setColumnStretch(col + 2, 1); m_dcaOverviewMembers.append(members); } } +void DCAMappingPanel::updateActiveDcaChecks() { + if (!m_app || !m_app->show()) + return; + const bool wasUpdating = m_updatingUi; + m_updatingUi = true; + for (int d = 1; d <= m_dcaActiveChecks.size(); ++d) { + const bool active = m_app->show()->isDcaActive(d); + m_dcaActiveChecks[d - 1]->setChecked(active); + // grey the row so a reserved DCA reads as out of OpenMix's hands + if (d <= m_dcaOverviewTitles.size()) + m_dcaOverviewTitles[d - 1]->setEnabled(active); + if (d <= m_dcaOverviewMembers.size()) + m_dcaOverviewMembers[d - 1]->setEnabled(active); + } + m_updatingUi = wasUpdating; +} + void DCAMappingPanel::createChannelSection() { // clear existing while (QLayoutItem* item = m_channelLayout->takeAt(0)) { @@ -526,6 +563,7 @@ void DCAMappingPanel::populateFromMapping() { m_updatingUi = false; updateComboItemStates(); + updateActiveDcaChecks(); updateDcaOverview(); } @@ -610,27 +648,40 @@ void DCAMappingPanel::updateComboItemStates() { QStringList itemTexts; QStringList compactTexts; + QList dcaActive; for (int d = 1; d <= m_dcaCount; ++d) { + const bool active = !m_app || !m_app->show() || m_app->show()->isDcaActive(d); + dcaActive << active; const int total = channelCounts[d] + busCounts[d]; - const QString name = dcaDisplayName(d); - const QString plain = tr("DCA %1").arg(d); + QString name = dcaDisplayName(d); + QString plain = tr("DCA %1").arg(d); + if (!active) { + name = tr("%1 (off)").arg(name); + plain = tr("%1 (off)").arg(plain); + } itemTexts << (total > 0 ? tr("%1 (%2)").arg(name).arg(total) : name); compactTexts << (total > 0 ? tr("%1 (%2)").arg(plain).arg(total) : plain); } - for (QComboBox* combo : m_channelCombos) { + // inactive DCAs stay visible (existing assignments are preserved and shown) + // but can't be newly picked + auto applyItemStates = [&](QComboBox* combo) { + auto* model = qobject_cast(combo->model()); for (int d = 1; d <= m_dcaCount; ++d) { combo->setItemText(d, itemTexts[d - 1]); combo->setItemData(d, compactTexts[d - 1], NoScrollComboBox::CompactTextRole); + if (model) { + if (QStandardItem* item = model->item(d)) + item->setEnabled(dcaActive[d - 1]); + } } - } + }; - for (QComboBox* combo : m_busCombos) { - for (int d = 1; d <= m_dcaCount; ++d) { - combo->setItemText(d, itemTexts[d - 1]); - combo->setItemData(d, compactTexts[d - 1], NoScrollComboBox::CompactTextRole); - } - } + for (QComboBox* combo : m_channelCombos) + applyItemStates(combo); + + for (QComboBox* combo : m_busCombos) + applyItemStates(combo); } void DCAMappingPanel::refresh() { diff --git a/src/ui/DCAMappingPanel.h b/src/ui/DCAMappingPanel.h index 1318963..88bd40f 100644 --- a/src/ui/DCAMappingPanel.h +++ b/src/ui/DCAMappingPanel.h @@ -79,6 +79,7 @@ class DCAMappingPanel : public QWidget { void updateComboItemStates(); void updateContextHeader(); void updateDcaOverview(); + void updateActiveDcaChecks(); QString busDisplayName(int bus) const; QString channelDisplayName(int channel) const; QString dcaDisplayName(int dca) const; @@ -109,6 +110,7 @@ class DCAMappingPanel : public QWidget { QGridLayout* m_dcaOverviewLayout = nullptr; QVector m_dcaOverviewTitles; QVector m_dcaOverviewMembers; + QVector m_dcaActiveChecks; QGroupBox* m_channelGroup; QGridLayout* m_channelLayout; diff --git a/src/ui/MixerFeedbackPanel.cpp b/src/ui/MixerFeedbackPanel.cpp index 0f0d947..959770b 100644 --- a/src/ui/MixerFeedbackPanel.cpp +++ b/src/ui/MixerFeedbackPanel.cpp @@ -30,6 +30,8 @@ MixerFeedbackPanel::MixerFeedbackPanel(Application* app, QWidget* parent) connect(m_app, &Application::mixerDisconnected, this, &MixerFeedbackPanel::onMixerDisconnected); connect(m_app, &Application::dcaCountChanged, this, &MixerFeedbackPanel::setDCACount); + connect(m_app, &Application::activeDcasChanged, this, + &MixerFeedbackPanel::applyActiveDcaVisibility); setDCACount(m_app->effectiveDcaCount()); if (m_app->mixer() && m_app->mixer()->isConnected()) { @@ -146,6 +148,17 @@ void MixerFeedbackPanel::setDCACount(int count) { if (m_app && m_app->mixer() && m_app->mixer()->isConnected()) refresh(); } + + applyActiveDcaVisibility(); +} + +void MixerFeedbackPanel::applyActiveDcaVisibility() { + if (!m_app) + return; + // direct visibility, not setVisibleDCAs: its empty-list-means-all + // convention would show everything when every DCA is inactive + for (DCAWidget* dca : m_dcaWidgets) + dca->setVisible(m_app->isDcaActive(dca->dcaNumber())); } void MixerFeedbackPanel::setVisibleDCAs(const QVector& dcaNumbers) { diff --git a/src/ui/MixerFeedbackPanel.h b/src/ui/MixerFeedbackPanel.h index bad20ca..ac91f14 100644 --- a/src/ui/MixerFeedbackPanel.h +++ b/src/ui/MixerFeedbackPanel.h @@ -47,6 +47,7 @@ class MixerFeedbackPanel : public QWidget { void onTabToPrevious(int dcaNumber); private: + void applyActiveDcaVisibility(); bool isAnyLabelBeingEdited() const; DCAWidget* firstEditableDCA() const; DCAWidget* lastEditableDCA() const; diff --git a/tests/test_actor_profiles.cpp b/tests/test_actor_profiles.cpp index 22ab67e..7bf117f 100644 --- a/tests/test_actor_profiles.cpp +++ b/tests/test_actor_profiles.cpp @@ -423,7 +423,7 @@ class TestActorProfiles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.9")); + QCOMPARE(json["version"].toString(), QString("1.10")); Show loaded; loaded.fromJson(json); diff --git a/tests/test_ensembles.cpp b/tests/test_ensembles.cpp index 8ce9a94..e3302d9 100644 --- a/tests/test_ensembles.cpp +++ b/tests/test_ensembles.cpp @@ -113,7 +113,7 @@ class TestEnsembles : public QObject { QVERIFY(spy.count() >= 1); const QJsonObject json = show.toJson(); - QCOMPARE(json["version"].toString(), QString("1.9")); + QCOMPARE(json["version"].toString(), QString("1.10")); Show loaded; loaded.fromJson(json); diff --git a/tests/test_playback_profiles.cpp b/tests/test_playback_profiles.cpp index 59383a4..1844f48 100644 --- a/tests/test_playback_profiles.cpp +++ b/tests/test_playback_profiles.cpp @@ -3,6 +3,7 @@ #include "core/ActorProfileLibrary.h" #include "core/Cue.h" #include "core/CueList.h" +#include "core/DCAMapping.h" #include "core/FadeEngine.h" #include "core/PlaybackEngine.h" #include "protocol/LoopbackProtocol.h" @@ -159,6 +160,68 @@ class TestPlaybackProfiles : public QObject { QVERIFY(!engine.fadeEngine()->isActive()); } + void inactiveDca_overridesNeverReachConsole() { + CueList cues; + Cue cue(1.0, "Top"); + cue.setType(CueType::Snapshot); + DCAOverride active; + active.mute = true; + active.label = "Leads"; + cue.setDCAOverride(1, active); + DCAOverride reserved; + reserved.mute = true; + reserved.label = "Band"; + cue.setDCAOverride(2, reserved); + cues.addCue(cue); + + LoopbackProtocol* mixer = makeConnectedLoopback(this); + // loopback pre-seeds console state; capture the reserved DCA's values + // so "untouched" means "unchanged", not "absent" + const QVariant seededMute = mixer->getParameter("/dca/2/mute"); + const QVariant seededName = mixer->getParameter("/dca/2/config/name"); + + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.setInactiveDcas({2}); + engine.executeCue(0); + + // active DCA written, reserved DCA untouched + QCOMPARE(mixer->getParameter("/dca/1/mute").toInt(), 1); + QCOMPARE(mixer->getParameter("/dca/1/config/name").toString(), QString("Leads")); + QCOMPARE(mixer->getParameter("/dca/2/mute"), seededMute); + QCOMPARE(mixer->getParameter("/dca/2/config/name"), seededName); + } + + void inactiveDca_mappedChannelParamsFiltered() { + DCAMapping mapping; + mapping.assignChannelToDCA(5, 2); // ch 5 rides the reserved DCA + mapping.assignChannelToDCA(6, 1); + + CueList cues; + Cue cue(1.0, "Top"); + cue.setType(CueType::Snapshot); + cue.setParameter("/ch/05/mix/fader", 0.7); + cue.setParameter("/ch/06/mix/fader", 0.3); + cue.setParameter("/main/st/mix/fader", 0.9); // global param always sends + cues.addCue(cue); + + LoopbackProtocol* mixer = makeConnectedLoopback(this); + // loopback pre-seeds channel state; "filtered" = value unchanged + const QVariant seededCh5 = mixer->getParameter("/ch/05/mix/fader"); + + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setDCAMapping(&mapping); + engine.setMixer(mixer); + engine.setInactiveDcas({2}); + engine.executeCue(0); + + QCOMPARE(mixer->getParameter("/ch/05/mix/fader"), seededCh5); + QCOMPARE(mixer->getParameter("/ch/06/mix/fader").toDouble(), 0.3); + QCOMPARE(mixer->getParameter("/main/st/mix/fader").toDouble(), 0.9); + } + void verifyCue_emitsLanded_whenParamsMatch() { CueList cues; Cue a(1.0, "A"); diff --git a/tests/test_show.cpp b/tests/test_show.cpp index 61aebd6..55a1a86 100644 --- a/tests/test_show.cpp +++ b/tests/test_show.cpp @@ -1,4 +1,5 @@ #include "core/Show.h" +#include #include #include @@ -139,6 +140,41 @@ class TestShow : public QObject { QCOMPARE(spy.count(), 1); QCOMPARE(spy.at(0).at(0).toBool(), false); } + + void inactiveDcas_roundTripAndSignals() { + Show show; + QSignalSpy changed(&show, &Show::activeDcasChanged); + + show.setDcaActive(3, false); + show.setDcaActive(7, false); + show.setDcaActive(3, false); // no-op: already inactive + QCOMPARE(changed.count(), 2); + QVERIFY(show.isModified()); + QVERIFY(!show.isDcaActive(3)); + QVERIFY(show.isDcaActive(1)); + + Show other; + other.fromJson(show.toJson()); + QCOMPARE(other.inactiveDcas(), QSet({3, 7})); + QVERIFY(!other.isModified()); + + show.setDcaActive(3, true); + QCOMPARE(show.inactiveDcas(), QSet({7})); + } + + void inactiveDcas_missingKeyMeansAllActive() { + QJsonObject legacy; + legacy["version"] = "1.9"; + legacy["name"] = "Legacy"; + legacy["cues"] = QJsonArray(); + + Show show; + show.setDcaActive(2, false); // pre-existing state must clear on load + show.fromJson(legacy); + + QVERIFY(show.inactiveDcas().isEmpty()); + QVERIFY(show.isDcaActive(2)); + } }; QTEST_MAIN(TestShow) From 9a9de92d9e5d7e5e535519ec8cbf56b52307fa88 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 5 Jul 2026 14:52:03 -0400 Subject: [PATCH 6/7] feat: spreadsheet-style TSV paste across a cue's DCA cells Mixers keep per-cue DCA sheets in spreadsheets; entering a row meant retyping every cell. Ctrl+V on a DCA assignment cell now spreads tab-separated clipboard values rightward across visible DCA columns (skips fx/pos and inactive DCAs, clamps at the last column, empty cells clear), committed through the same label-resolution path as typing and undone as a single step. Ctrl+C on a DCA cell copies the labels back out as TSV. Elsewhere both shortcuts keep whole-cue semantics; the context menu gets explicit Copy/Paste DCA Assignments entries. --- src/ui/CastTextParse.h | 15 ++++++ src/ui/CueListView.cpp | 94 ++++++++++++++++++++++++++++++++++ src/ui/CueListView.h | 7 +++ src/ui/MainWindow.cpp | 21 ++++++-- tests/test_cast_text_parse.cpp | 22 ++++++++ 5 files changed, 154 insertions(+), 5 deletions(-) diff --git a/src/ui/CastTextParse.h b/src/ui/CastTextParse.h index 569a7ae..3da3380 100644 --- a/src/ui/CastTextParse.h +++ b/src/ui/CastTextParse.h @@ -37,6 +37,21 @@ namespace OpenMix::CastTextParse { return merged; } +// First row of a spreadsheet-style clipboard grid: split the first line on +// tabs, trimming each cell (absorbs \r from \r\n). Empty cells are preserved +// (a paste overwrites the target cell); a blank/whitespace-only single cell +// returns an empty list. Later rows are ignored. +[[nodiscard]] inline QStringList parseTsvRow(const QString& text) { + const int lineEnd = text.indexOf(QLatin1Char('\n')); + const QString firstLine = lineEnd < 0 ? text : text.left(lineEnd); + QStringList cells = firstLine.split(QLatin1Char('\t')); + for (QString& cell : cells) + cell = cell.trimmed(); + if (cells.size() == 1 && cells.first().isEmpty()) + return {}; + return cells; +} + struct CastLine { QString name; QStringList roles; diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 3364d3b..d72e132 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -1,4 +1,5 @@ #include "CueListView.h" +#include "CastTextParse.h" #include "CueFilterBar.h" #include "CueFilterProxyModel.h" #include "CueItemDelegates.h" @@ -15,6 +16,7 @@ #include "protocol/MixerProtocol.h" #include +#include #include #include "theme/Theme.h" @@ -426,6 +428,83 @@ void CueListView::copySelectedCue() { m_clipboard = m_app->show()->cueList()->at(idx); } +// the current cell decides what copy/paste means: on a DCA assignment cell the +// shortcuts speak spreadsheet (system-clipboard TSV), everywhere else they keep +// the whole-cue semantics +void CueListView::copySmart() { + const QModelIndex anchor = currentDcaAssignmentIndex(); + if (anchor.isValid()) + copyDcaCells(anchor); + else + copySelectedCue(); +} + +void CueListView::pasteSmart() { + const QModelIndex anchor = currentDcaAssignmentIndex(); + if (anchor.isValid() && pasteDcaCells(anchor)) + return; + pasteCue(); +} + +QModelIndex CueListView::currentDcaAssignmentIndex() const { + const QModelIndex current = m_tableView->currentIndex(); + if (!current.isValid()) + return {}; + const QModelIndex src = m_proxyModel->mapToSource(current); + return m_model->dcaSubColumn(src.column()) == 0 ? current : QModelIndex(); +} + +void CueListView::copyDcaCells(const QModelIndex& proxyAnchor) { + const QModelIndex src = m_proxyModel->mapToSource(proxyAnchor); + // EditRole = the raw typed labels, so a copy round-trips to a spreadsheet + QStringList values; + for (int c = src.column(); c < m_model->columnCount(); ++c) { + if (m_model->dcaSubColumn(c) != 0 || m_tableView->isColumnHidden(c)) + continue; + values << m_model->index(src.row(), c).data(Qt::EditRole).toString(); + } + while (!values.isEmpty() && values.last().isEmpty()) + values.removeLast(); + if (values.isEmpty()) + return; // nothing to copy; don't clobber the clipboard + QApplication::clipboard()->setText(values.join(QLatin1Char('\t'))); +} + +bool CueListView::pasteDcaCells(const QModelIndex& proxyAnchor) { + if (m_editingLocked || !proxyAnchor.isValid()) + return false; + const QStringList values = + CastTextParse::parseTsvRow(QApplication::clipboard()->text()); + if (values.isEmpty()) + return false; + + const QModelIndex src = m_proxyModel->mapToSource(proxyAnchor); + CueList* cueList = m_app->show()->cueList(); + const int row = src.row(); + if (row < 0 || row >= cueList->count() || m_model->dcaSubColumn(src.column()) != 0) + return false; + + // spread rightward over visible assignment cells; fx/pos sub-columns and + // inactive DCAs (hidden columns) are skipped, extra values are dropped + QList targets; + for (int c = src.column(); c < m_model->columnCount() && targets.size() < values.size(); ++c) { + if (m_model->dcaSubColumn(c) == 0 && !m_tableView->isColumnHidden(c)) + targets << m_model->dcaOfColumn(c); + } + if (targets.isEmpty()) + return false; + + Cue oldCue = cueList->at(row); + Cue newCue = oldCue; + for (int i = 0; i < targets.size(); ++i) + m_model->applyDcaAssignment(newCue, targets[i], values[i]); + + m_app->undoStack()->push(new EditCueCommand(cueList, row, oldCue, newCue)); + cueList->updateCue(row, newCue); + emit cueSelected(row); // same refresh cascade a typed DCA-cell commit fires + return true; +} + void CueListView::pasteCue() { if (!m_clipboard) return; @@ -571,6 +650,7 @@ void CueListView::applyDcaSubColumnVisibility() { } void CueListView::setEditingLocked(bool locked) { + m_editingLocked = locked; m_tableView->setEditTriggers(locked ? QAbstractItemView::NoEditTriggers : QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed | @@ -610,6 +690,20 @@ void CueListView::showContextMenu(const QPoint& pos) { paste->setEnabled(hasClipboardCue()); connect(paste, &QAction::triggered, this, &CueListView::pasteCue); + // DCA assignment cells get spreadsheet-style TSV copy/paste. Use the + // clicked index, not currentIndex(): selectRow above moved the current cell + const bool onDcaCell = + at.isValid() && m_model->dcaSubColumn(m_proxyModel->mapToSource(at).column()) == 0; + if (onDcaCell) { + menu.addSeparator(); + QAction* copyDca = menu.addAction(tr("Copy DCA Assignments")); + connect(copyDca, &QAction::triggered, this, [this, at]() { copyDcaCells(at); }); + QAction* pasteDca = menu.addAction(tr("Paste DCA Assignments")); + pasteDca->setEnabled(!m_editingLocked && + !QApplication::clipboard()->text().isEmpty()); + connect(pasteDca, &QAction::triggered, this, [this, at]() { pasteDcaCells(at); }); + } + if (row >= 0) { menu.addSeparator(); menu.addAction(tr("Jump to Standby"), this, &CueListView::jumpToSelectedCue); diff --git a/src/ui/CueListView.h b/src/ui/CueListView.h index eddcf5c..c2d1dad 100644 --- a/src/ui/CueListView.h +++ b/src/ui/CueListView.h @@ -45,6 +45,8 @@ class CueListView : public QWidget { void duplicateSelectedCue(); // clone the selected cue to the end of the list void cloneCueAfter(); // clone the selected cue in place, just after it void copySelectedCue(); + void copySmart(); // DCA cell current: copy assignments as TSV; else copySelectedCue + void pasteSmart(); // DCA cell current + text clipboard: TSV paste; else pasteCue void pasteCue(); // insert the clipboard cue as a new cue after selection void pasteCueMerge(); // merge clipboard content into the selected cue void pasteCueSwap(); // exchange content between clipboard and selected cue @@ -91,6 +93,10 @@ class CueListView : public QWidget { QModelIndex nextEditableIndex(const QModelIndex& current, bool forward) const; void insertCueAt(int index, const Cue& cue); // undoable insert + select void selectSourceRow(int sourceRow); + // proxy index of the current cell iff it is a DCA assignment cell + QModelIndex currentDcaAssignmentIndex() const; + void copyDcaCells(const QModelIndex& proxyAnchor); // TSV to system clipboard + bool pasteDcaCells(const QModelIndex& proxyAnchor); // TSV spread rightward, one undo step std::optional m_clipboard; // copied cue for paste operations @@ -105,6 +111,7 @@ class CueListView : public QWidget { int m_standbyCueIndex = -1; bool m_dcaFxColsVisible = false; // per-DCA fx sub-columns shown bool m_dcaPosColsVisible = false; // per-DCA pos sub-columns shown + bool m_editingLocked = false; // mirrors setEditingLocked, guards cell paste // delegates CueNumberDelegate* m_numberDelegate; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index dade72a..a845a4e 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -55,6 +55,7 @@ #include #include #include +#include #include #include #include @@ -94,6 +95,9 @@ MainWindow::MainWindow(Application* app, QWidget* parent) : QMainWindow(parent), createBubbleBar(); connectSignals(); loadSettings(); + // copying in a spreadsheet arms the paste action's DCA-cell branch + connect(QApplication::clipboard(), &QClipboard::dataChanged, this, + &MainWindow::updateActions); updateActions(); updateTitle(); @@ -196,14 +200,18 @@ void MainWindow::createActions() { m_copyCueAction = new QAction(tr("&Copy Cue"), this); m_copyCueAction->setShortcut(QKeySequence::Copy); - m_copyCueAction->setToolTip(tr("Copy the selected cue (Ctrl+C)")); + m_copyCueAction->setToolTip( + tr("Copy the selected cue; on a DCA cell, copy its assignments as " + "spreadsheet text (Ctrl+C)")); connect(m_copyCueAction, &QAction::triggered, this, - [this]() { m_cueListView->copySelectedCue(); updateActions(); }); + [this]() { m_cueListView->copySmart(); updateActions(); }); m_pasteCueAction = new QAction(tr("&Paste Cue"), this); m_pasteCueAction->setShortcut(QKeySequence::Paste); - m_pasteCueAction->setToolTip(tr("Paste the copied cue as a new cue (Ctrl+V)")); - connect(m_pasteCueAction, &QAction::triggered, this, [this]() { m_cueListView->pasteCue(); }); + m_pasteCueAction->setToolTip( + tr("Paste the copied cue as a new cue; on a DCA cell, paste tab-separated " + "names across the DCA cells (Ctrl+V)")); + connect(m_pasteCueAction, &QAction::triggered, this, [this]() { m_cueListView->pasteSmart(); }); m_pasteMergeAction = new QAction(tr("Paste &Merge"), this); m_pasteMergeAction->setToolTip(tr("Merge the copied cue's content into the selected cue")); @@ -1361,7 +1369,10 @@ void MainWindow::updateActions() { m_cloneCueAction->setEnabled(editable && hasSelection); m_cloneToEndAction->setEnabled(editable && hasSelection); m_copyCueAction->setEnabled(hasSelection); - m_pasteCueAction->setEnabled(editable && m_cueListView->hasClipboardCue()); + // stays armed for either branch of pasteSmart: an internal copied cue or + // spreadsheet text destined for DCA cells (pasteSmart no-ops gracefully) + m_pasteCueAction->setEnabled(editable && (m_cueListView->hasClipboardCue() || + !QApplication::clipboard()->text().isEmpty())); m_pasteMergeAction->setEnabled(editable && hasSelection && m_cueListView->hasClipboardCue()); m_pasteSwapAction->setEnabled(editable && hasSelection && m_cueListView->hasClipboardCue()); m_fillDownAction->setEnabled(editable && hasSelection); diff --git a/tests/test_cast_text_parse.cpp b/tests/test_cast_text_parse.cpp index 8319eb4..4afd3b5 100644 --- a/tests/test_cast_text_parse.cpp +++ b/tests/test_cast_text_parse.cpp @@ -79,6 +79,28 @@ class TestCastTextParse : public QObject { QCOMPARE(lines[0].roles, QStringList({"Cosette"})); QCOMPARE(lines[1].name, QString("Bob")); } + + void tsvRow_splitsFirstLineOnTabs() { + QCOMPARE(CastTextParse::parseTsvRow("Evan\tHeidi\tCynthia\tConnor\n"), + QStringList({"Evan", "Heidi", "Cynthia", "Connor"})); + } + + void tsvRow_ignoresLaterRows_absorbsCr() { + QCOMPARE(CastTextParse::parseTsvRow("a\tb\r\nc\td"), QStringList({"a", "b"})); + } + + void tsvRow_singleValueAndBlank() { + QCOMPARE(CastTextParse::parseTsvRow("Evan"), QStringList({"Evan"})); + QCOMPARE(CastTextParse::parseTsvRow(""), QStringList()); + QCOMPARE(CastTextParse::parseTsvRow(" "), QStringList()); + QCOMPARE(CastTextParse::parseTsvRow(" \r\n"), QStringList()); + } + + void tsvRow_preservesInteriorEmptyCells() { + QCOMPARE(CastTextParse::parseTsvRow("a\t\tb"), QStringList({"a", "", "b"})); + // a tabbed row of empties is a real (clearing) paste, not a blank + QCOMPARE(CastTextParse::parseTsvRow("\t"), QStringList({"", ""})); + } }; QTEST_MAIN(TestCastTextParse) From 4be70a06535c20bfd9a12f4efeb9f98d10c9a922 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sun, 5 Jul 2026 15:13:06 -0400 Subject: [PATCH 7/7] chore: bump version to 1.0.4 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index cdefb3d..4e6a093 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(OpenMix - VERSION 1.0.3 + VERSION 1.0.4 DESCRIPTION "Stage mixing software for live theatre that lets engineers program, automate, and run cues seamlessly across 30+ digital mixing consoles" LANGUAGES CXX )