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 ) 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/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/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/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/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/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/CueEditor.cpp b/src/ui/CueEditor.cpp index 0954193..9d863e9 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()) { @@ -60,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(); } @@ -388,6 +400,7 @@ void CueEditor::rebuildDcaTargetChecks(int count) { onTargetedDCAsChanged(); }); } + applyActiveDcaVisibility(); } void CueEditor::rebuildDcaOverrideStrips(int count) { @@ -404,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) { @@ -694,13 +719,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 +1026,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..4bde636 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,8 @@ class CueEditor : public QWidget { void onDcaCountChanged(int count); private: + [[nodiscard]] EnsembleLibrary* ensembleLibrary() const; + void applyActiveDcaVisibility(); void setupUi(); void createDCATargetingSection(); void rebuildDcaTargetChecks(int count); diff --git a/src/ui/CueItemDelegates.cpp b/src/ui/CueItemDelegates.cpp index bf7719e..53cb620 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; @@ -272,11 +297,15 @@ 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 { + if (skipPaintForOpenEditor(painter, option, index)) + return; + QStyleOptionViewItem opt = option; opt.state &= ~QStyle::State_HasFocus; @@ -302,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); @@ -349,6 +378,9 @@ CueTextDelegate::CueTextDelegate(QObject* parent) : QStyledItemDelegate(parent) void CueTextDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { + if (skipPaintForOpenEditor(painter, option, index)) + return; + QStyleOptionViewItem opt = option; opt.state &= ~QStyle::State_HasFocus; 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..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" @@ -67,6 +69,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); }); @@ -108,6 +112,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 @@ -200,7 +208,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 @@ -419,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; @@ -544,23 +630,27 @@ 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); } } void CueListView::setEditingLocked(bool locked) { + m_editingLocked = locked; m_tableView->setEditTriggers(locked ? QAbstractItemView::NoEditTriggers : QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed | @@ -600,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/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 f7527f8..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)) { @@ -372,6 +409,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 +479,7 @@ void DCAMappingPanel::createBusSection() { int dca = m_busCombos[bus - 1]->currentData().toInt(); onBusDCAChanged(bus, dca); updateComboItemStates(); + updateDcaOverview(); } }); m_busLayout->addWidget(combo, row, colOffset + 1); @@ -524,6 +563,7 @@ void DCAMappingPanel::populateFromMapping() { m_updatingUi = false; updateComboItemStates(); + updateActiveDcaChecks(); updateDcaOverview(); } @@ -608,36 +648,63 @@ 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() { 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"), @@ -885,17 +952,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..88bd40f 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(); @@ -77,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; @@ -107,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/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/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/src/ui/MixerFeedbackPanel.cpp b/src/ui/MixerFeedbackPanel.cpp index c90630b..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()) { @@ -114,7 +116,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; @@ -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/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..7bf117f 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); @@ -325,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_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) 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"); 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_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); } 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)