diff --git a/resources/styles/main.qss b/resources/styles/main.qss index 5aafb93..fa5e5e7 100644 --- a/resources/styles/main.qss +++ b/resources/styles/main.qss @@ -238,6 +238,17 @@ QLineEdit[placeholderText] { color: #6b7280; } +/* inline editors inside item views must fit their row: the input box model + above (padding + min-height) otherwise overflows compact rows */ +QTreeView QLineEdit, +QTableView QLineEdit, +QListView QLineEdit { + border: none; + border-radius: 0; + padding: 0 4px; + min-height: 0; +} + QComboBox::drop-down { border: none; width: 24px; diff --git a/src/core/Actor.cpp b/src/core/Actor.cpp index 5d18a36..fd7ab92 100644 --- a/src/core/Actor.cpp +++ b/src/core/Actor.cpp @@ -21,6 +21,17 @@ QString Actor::matchedRole(const QString& text) const { return {}; } +QString Actor::secondaryName(const QString& matchedRole) const { + QString secondary; + if (m_useRoleName && !m_roles.isEmpty()) + secondary = m_name; + else + secondary = matchedRole.isEmpty() ? primaryRole() : matchedRole; + if (secondary.compare(displayName(), Qt::CaseInsensitive) == 0) + return {}; + return secondary; +} + QJsonObject Actor::toJson() const { QJsonObject json; json["id"] = m_id; @@ -33,6 +44,8 @@ QJsonObject Actor::toJson() const { json["channel"] = m_channel; json["order"] = m_order; json["active"] = m_active; + if (m_useRoleName) + json["useRoleName"] = true; if (!m_profiles.isEmpty()) { QJsonObject profilesObj; @@ -68,6 +81,7 @@ Actor Actor::fromJson(const QJsonObject& json) { actor.m_channel = json["channel"].toInt(); actor.m_order = json["order"].toInt(); actor.m_active = json["active"].toBool(true); + actor.m_useRoleName = json["useRoleName"].toBool(false); const QJsonObject profilesObj = json["profiles"].toObject(); for (auto it = profilesObj.constBegin(); it != profilesObj.constEnd(); ++it) diff --git a/src/core/Actor.h b/src/core/Actor.h index 9806cf0..639ae1d 100644 --- a/src/core/Actor.h +++ b/src/core/Actor.h @@ -31,6 +31,15 @@ class Actor { // the stored role case-insensitively equal to text, or empty when none [[nodiscard]] QString matchedRole(const QString& text) const; + [[nodiscard]] bool useRoleName() const noexcept { return m_useRoleName; } + void setUseRoleName(bool use) { m_useRoleName = use; } + + [[nodiscard]] QString displayName() const { + return (m_useRoleName && !m_roles.isEmpty()) ? m_roles.first() : m_name; + } + // the parenthesised label paired with displayName(); empty when redundant + [[nodiscard]] QString secondaryName(const QString& matchedRole = QString()) const; + [[nodiscard]] int channel() const noexcept { return m_channel; } void setChannel(int channel) { m_channel = channel; } @@ -57,6 +66,7 @@ class Actor { int m_channel = 0; int m_order = 0; bool m_active = true; + bool m_useRoleName = false; QMap m_profiles; // slot -> profile }; diff --git a/src/core/ActorProfile.h b/src/core/ActorProfile.h index c526d8a..af69cd9 100644 --- a/src/core/ActorProfile.h +++ b/src/core/ActorProfile.h @@ -11,11 +11,11 @@ namespace OpenMix { // scales them to the console's wire format. struct EqBand { int band = 1; // 1-based band index - bool on = true; // + bool on = true; int type = 0; // console EQ type enum (PEQ / shelf / ...), driver-mapped double freq = 1000.0; // Hz double gain = 0.0; // dB - double q = 2.0; // + double q = 2.0; QJsonObject toJson() const; [[nodiscard]] static EqBand fromJson(const QJsonObject& json); diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 78794df..a7757be 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -26,6 +26,8 @@ void PlaybackEngine::setCueList(CueList* cueList) { stop(); if (m_cueList) { connect(m_cueList, &CueList::cueRemoved, this, &PlaybackEngine::onCueRemoved); + connect(m_cueList, &CueList::listCleared, this, &PlaybackEngine::onCueListReset); + connect(m_cueList, &CueList::listLoaded, this, &PlaybackEngine::onCueListReset); if (!m_cueList->isEmpty()) setStandbyIndex(0); } @@ -54,6 +56,12 @@ void PlaybackEngine::onCueRemoved(int index) { } } +void PlaybackEngine::onCueListReset() { + m_fadeEngine.cancelAll(); + m_appliedChannelLevels.clear(); + stop(); +} + void PlaybackEngine::setMixer(MixerProtocol* mixer) { m_mixer = mixer; } void PlaybackEngine::setDCAMapping(DCAMapping* mapping) { m_dcaMapping = mapping; } diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index 7b3738e..6294335 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -134,6 +134,7 @@ class PlaybackEngine : public QObject { private slots: void onAutoFollowTimerTimeout(); void onCueRemoved(int index); + void onCueListReset(); private: void setState(PlaybackState state); diff --git a/src/core/ScribbleController.cpp b/src/core/ScribbleController.cpp index ce44b03..90cf62e 100644 --- a/src/core/ScribbleController.cpp +++ b/src/core/ScribbleController.cpp @@ -96,7 +96,7 @@ void ScribbleController::refreshNames() { if (ch == m_cueChannel) continue; // reserved for the cue number if (const Actor* actor = m_library->actorForChannel(ch)) - m_mixer->setChannelName(ch, actor->name()); + m_mixer->setChannelName(ch, actor->displayName()); } } diff --git a/src/core/ShortcutManager.cpp b/src/core/ShortcutManager.cpp index b9e54d5..edd2942 100644 --- a/src/core/ShortcutManager.cpp +++ b/src/core/ShortcutManager.cpp @@ -21,7 +21,6 @@ void ShortcutManager::registerAction(const QString& id, QAction* action, m_shortcuts[id] = info; - // apply the default shortcut to the action action->setShortcut(defaultShortcut); } diff --git a/src/io/TmixImporter.cpp b/src/io/TmixImporter.cpp index 8af4929..64dd80c 100644 --- a/src/io/TmixImporter.cpp +++ b/src/io/TmixImporter.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -66,6 +67,35 @@ void clearShow(Show* show) { show->ensembleLibrary()->removeEnsemble(e.id()); } +// name the actor on `channel`: an existing named actor wins (the actors +// table is authoritative), an unnamed one is filled in, else a new one is cast +void applyChannelName(Show* show, int channel, const QString& name, + TmixImportSummary* summary) { + if (channel <= 0 || name.isEmpty()) + return; + ActorProfileLibrary* lib = show->actorProfileLibrary(); + const QList actors = lib->actors(); // copy: updateActor mutates the list + for (const Actor& a : actors) { + if (a.channel() != channel) + continue; + if (!a.name().isEmpty()) + return; + Actor copy = a; + copy.setName(name); + lib->updateActor(a.id(), copy); + if (summary) + ++summary->channelNames; + return; + } + Actor a(name, channel); + a.setOrder(lib->actorCount()); + lib->addActor(a); + if (summary) { + ++summary->actors; + ++summary->channelNames; + } +} + } // namespace bool TmixImporter::import(const QString& path, Show* show, QString* error, @@ -107,25 +137,8 @@ bool TmixImporter::import(const QString& path, Show* show, QString* error, } } - // channel voice presets become profile slots; map file id to slot name - QHash profileSlotMap; - if (q.exec("SELECT * FROM profiles")) { - while (q.next()) { - const QSqlRecord r = q.record(); - QString label = r.value("label").toString(); - if (label.isEmpty()) - label = r.value("name").toString(); - if (!label.isEmpty()) { - show->actorProfileLibrary()->addSlot(label); - if (summary) - summary->profileSlots.append(label); - } - if (r.indexOf("id") >= 0) - profileSlotMap.insert(r.value("id").toInt(), label); - } - } - - // actors + // actors: authoritative cast when present; usually empty, with the + // cast carried by the profiles table instead if (q.exec("SELECT * FROM actors")) { while (q.next()) { const QSqlRecord r = q.record(); @@ -138,6 +151,48 @@ bool TmixImporter::import(const QString& path, Show* show, QString* error, } } + // profiles: a channel's default row carries its Show Setup name and + // fills the actor name; additional presets become voice slots, and + // flat files without a channel column treat every label as a slot + QHash profileSlotMap; + { + const QSqlRecord layout = db.record("profiles"); + const bool perChannel = layout.indexOf("channel") >= 0; + const bool hasDefaultFlag = layout.indexOf("default") >= 0; + const bool hasLabel = layout.indexOf("label") >= 0; + QSet defaultSeen; + if (q.exec("SELECT * FROM profiles")) { + while (q.next()) { + const QSqlRecord r = q.record(); + QString label = hasLabel ? r.value("label").toString() : QString(); + if (label.isEmpty()) + label = r.value("name").toString(); + const int id = r.indexOf("id") >= 0 ? r.value("id").toInt() : -1; + + if (perChannel) { + const int channel = r.value("channel").toInt(); + const bool isDefault = hasDefaultFlag + ? r.value("default").toInt() != 0 + : !defaultSeen.contains(channel); + if (isDefault) { + defaultSeen.insert(channel); + applyChannelName(show, channel, label.trimmed(), summary); + if (id >= 0) + profileSlotMap.insert(id, ActorProfileLibrary::DEFAULT_SLOT); + continue; + } + } + if (!label.isEmpty()) { + show->actorProfileLibrary()->addSlot(label); + if (summary && !summary->profileSlots.contains(label)) + summary->profileSlots.append(label); + } + if (id >= 0) + profileSlotMap.insert(id, label); + } + } + } + // ensembles if (q.exec("SELECT * FROM ensembles")) { while (q.next()) { diff --git a/src/io/TmixImporter.h b/src/io/TmixImporter.h index 886ca88..be488c4 100644 --- a/src/io/TmixImporter.h +++ b/src/io/TmixImporter.h @@ -14,13 +14,17 @@ struct TmixImportSummary { int positions = 0; int ensembles = 0; int rolesInferred = 0; // actor roles guessed from cue DCA labels + int channelNames = 0; // actor names taken from default profile rows QStringList profileSlots; }; // Imports a .tmix show file (a SQLite database) into a Show. // The show is cleared first, then populated from the file's config, actors, -// profiles, positions, ensembles and cues tables. Best-effort: missing tables -// or columns are skipped rather than treated as errors. +// profiles, positions, ensembles and cues tables. These files store each +// channel's Show Setup name as that channel's default profile row, so default +// profiles become actor names and only additional profiles become voice +// slots. Best-effort: missing tables or columns are skipped rather than +// treated as errors. class TmixImporter { public: // Populate `show` from the .tmix at `path`. Returns false and sets *error diff --git a/src/protocol/allenheath/AllenHeathTcpProtocol.cpp b/src/protocol/allenheath/AllenHeathTcpProtocol.cpp index 8e8c875..0d266a5 100644 --- a/src/protocol/allenheath/AllenHeathTcpProtocol.cpp +++ b/src/protocol/allenheath/AllenHeathTcpProtocol.cpp @@ -159,7 +159,6 @@ QByteArray AllenHeathTcpProtocol::buildDCAFaderMessage(int dca, float level) { return msg; } -// buildDCAMuteMessage QByteArray AllenHeathTcpProtocol::buildDCAMuteMessage(int dca, bool muted) { if (dca < 1) return {}; @@ -177,7 +176,6 @@ QByteArray AllenHeathTcpProtocol::buildDCAMuteMessage(int dca, bool muted) { return msg; } -// buildSceneRecallMessage QByteArray AllenHeathTcpProtocol::buildSceneRecallMessage(int sceneNumber) { QByteArray msg; diff --git a/src/protocol/behringer/WingProtocol.cpp b/src/protocol/behringer/WingProtocol.cpp index ed59372..7170040 100644 --- a/src/protocol/behringer/WingProtocol.cpp +++ b/src/protocol/behringer/WingProtocol.cpp @@ -22,20 +22,16 @@ WingProtocol::WingProtocol(const MixerCapabilities& caps, QObject* parent) QObject::connect(&m_transport, &OscTransport::messageReceived, this, &WingProtocol::onMessageReceived); - // keep-alive timer QObject::connect(&m_keepAliveTimer, &QTimer::timeout, this, &WingProtocol::onKeepAliveTimeout); - // connection timeout timer m_connectionTimer.setSingleShot(true); QObject::connect(&m_connectionTimer, &QTimer::timeout, this, &WingProtocol::onConnectionTimeout); - // request timeout check timer m_requestTimeoutTimer.setInterval(500); QObject::connect(&m_requestTimeoutTimer, &QTimer::timeout, this, &WingProtocol::onRequestTimeoutCheck); - // reconnection timer m_reconnectTimer.setSingleShot(true); QObject::connect(&m_reconnectTimer, &QTimer::timeout, this, &WingProtocol::onReconnectAttempt); } diff --git a/src/ui/ActorSetupPanel.cpp b/src/ui/ActorSetupPanel.cpp index e067b55..8c6069f 100644 --- a/src/ui/ActorSetupPanel.cpp +++ b/src/ui/ActorSetupPanel.cpp @@ -421,7 +421,7 @@ void ActorSetupPanel::setupUi() { m_actorTree->setColumnCount(4); m_actorTree->setHeaderLabels({tr("Actor"), tr("Role"), tr("Ch"), tr("Active")}); m_actorTree->setRootIsDecorated(false); - m_actorTree->setSelectionMode(QAbstractItemView::SingleSelection); + m_actorTree->setSelectionMode(QAbstractItemView::ExtendedSelection); m_actorTree->header()->setStretchLastSection(false); m_actorTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); m_actorTree->header()->setSectionResizeMode(1, QHeaderView::Stretch); @@ -437,24 +437,27 @@ void ActorSetupPanel::setupUi() { leftLayout->addWidget(m_actorTree, 1); auto* listButtons = new QHBoxLayout(); - m_addActorBtn = new QPushButton(Icons::listAdd(), tr("Add"), left); - m_addActorsBtn = new QPushButton(Icons::listAdd(), tr("Add Multiple..."), left); - m_addActorsBtn->setToolTip(tr("Add several actors at once: paste a cast list, one per line")); + m_addActorsBtn = new QPushButton(Icons::listAdd(), tr("Add Actors..."), left); + m_addActorsBtn->setToolTip(tr("Add one or more actors: one per line, roles after a tab; " + "pasting Name and Role columns from a spreadsheet works")); + m_addRolesBtn = new QPushButton(Icons::listAdd(), tr("Add Roles..."), left); + m_addRolesBtn->setToolTip(tr("Append roles to the selected actor(s): commas or one per line")); m_removeActorBtn = new QPushButton(Icons::listRemove(), tr("Remove"), left); + m_removeActorBtn->setToolTip(tr("Remove the selected actor(s)")); m_moveUpBtn = new QPushButton(Icons::moveUp(), QString(), left); m_moveUpBtn->setToolTip(tr("Move actor up")); m_moveDownBtn = new QPushButton(Icons::moveDown(), QString(), left); m_moveDownBtn->setToolTip(tr("Move actor down")); - listButtons->addWidget(m_addActorBtn); listButtons->addWidget(m_addActorsBtn); + listButtons->addWidget(m_addRolesBtn); listButtons->addWidget(m_removeActorBtn); listButtons->addStretch(); listButtons->addWidget(m_moveUpBtn); listButtons->addWidget(m_moveDownBtn); leftLayout->addLayout(listButtons); - connect(m_addActorBtn, &QPushButton::clicked, this, &ActorSetupPanel::addActor); connect(m_addActorsBtn, &QPushButton::clicked, this, &ActorSetupPanel::addActors); + connect(m_addRolesBtn, &QPushButton::clicked, this, &ActorSetupPanel::addRoles); connect(m_removeActorBtn, &QPushButton::clicked, this, &ActorSetupPanel::removeActor); connect(m_moveUpBtn, &QPushButton::clicked, this, &ActorSetupPanel::moveActorUp); connect(m_moveDownBtn, &QPushButton::clicked, this, &ActorSetupPanel::moveActorDown); @@ -499,12 +502,17 @@ void ActorSetupPanel::setupUi() { tr("Typing any of these roles into a cue's DCA slot assigns this actor's channel")); m_channelSpin = new QSpinBox(identityBox); m_channelSpin->setRange(1, 96); + m_useRoleCheck = new QCheckBox(tr("Label channel with role name"), identityBox); + m_useRoleCheck->setToolTip( + tr("Console scribble strips and channel lists show the primary role instead of the " + "actor name; useful when the channel isn't tied to one person")); m_activeCheck = new QCheckBox(tr("Active"), identityBox); m_activeCheck->setToolTip(tr("Inactive actors yield their channel to the next understudy")); m_backupCheck = new QCheckBox(tr("Channel on backup / spare mic"), identityBox); m_backupCheck->setToolTip(tr("Resolve this channel to the backup voice instead of the main")); identityForm->addRow(tr("Name:"), m_nameEdit); identityForm->addRow(tr("Roles:"), m_rolesEdit); + identityForm->addRow(QString(), m_useRoleCheck); identityForm->addRow(tr("Channel:"), m_channelSpin); identityForm->addRow(QString(), m_activeCheck); identityForm->addRow(QString(), m_backupCheck); @@ -515,6 +523,7 @@ void ActorSetupPanel::setupUi() { connect(m_channelSpin, QOverload::of(&QSpinBox::valueChanged), this, &ActorSetupPanel::onChannelChanged); connect(m_activeCheck, &QCheckBox::toggled, this, &ActorSetupPanel::onActiveToggled); + connect(m_useRoleCheck, &QCheckBox::toggled, this, &ActorSetupPanel::onUseRoleNameToggled); connect(m_backupCheck, &QCheckBox::toggled, this, &ActorSetupPanel::onBackupToggled); auto* slotBox = new QGroupBox(tr("Voice Profile Slots (shared by all actors)"), m_editor); @@ -591,6 +600,22 @@ QString ActorSetupPanel::selectedActorId() const { return item ? item->data(0, Qt::UserRole).toString() : QString(); } +QStringList ActorSetupPanel::selectedActorIds() const { + QStringList ids; + const QList items = m_actorTree->selectedItems(); + for (const QTreeWidgetItem* item : items) { + const QString id = item->data(0, Qt::UserRole).toString(); + if (!id.isEmpty()) + ids.append(id); + } + if (ids.isEmpty()) { + const QString current = selectedActorId(); + if (!current.isEmpty()) + ids.append(current); + } + return ids; +} + void ActorSetupPanel::refresh() { if (m_app && m_app->show()) m_library = m_app->show()->actorProfileLibrary(); @@ -668,6 +693,7 @@ void ActorSetupPanel::loadActorIntoEditor() { m_rolesEdit->clear(); m_channelSpin->setValue(1); m_activeCheck->setChecked(false); + m_useRoleCheck->setChecked(false); m_backupCheck->setChecked(false); m_mainVoice->setVoice(VoiceData()); m_backupVoice->setVoice(VoiceData()); @@ -683,6 +709,7 @@ void ActorSetupPanel::loadActorIntoEditor() { m_rolesEdit->setText(a->rolesDisplay()); m_channelSpin->setValue(a->channel()); m_activeCheck->setChecked(a->active()); + m_useRoleCheck->setChecked(a->useRoleName()); m_backupCheck->setChecked(m_library->isBackup(a->channel())); const ActorProfile profile = a->profile(m_currentSlot); @@ -697,7 +724,9 @@ void ActorSetupPanel::setEditorEnabled(bool on) { void ActorSetupPanel::updateButtonStates() { const bool hasSel = !selectedActorId().isEmpty(); - m_removeActorBtn->setEnabled(hasSel); + const bool hasAnySel = !selectedActorIds().isEmpty(); + m_removeActorBtn->setEnabled(hasAnySel); + m_addRolesBtn->setEnabled(hasAnySel); m_copyBtn->setEnabled(hasSel); m_pasteBtn->setEnabled(hasSel && !m_copiedProfiles.isEmpty()); m_saveGroupBtn->setEnabled(m_library && m_library->actorCount() > 0); @@ -708,39 +737,13 @@ void ActorSetupPanel::updateButtonStates() { m_moveDownBtn->setEnabled(idx >= 0 && idx < m_actorTree->topLevelItemCount() - 1); } -void ActorSetupPanel::addActor() { - if (!m_library) - return; - - // choose the lowest free channel and a unique-ish order at the end - QSet used; - int maxOrder = 0; - for (const Actor& a : m_library->actors()) { - used.insert(a.channel()); - maxOrder = std::max(maxOrder, a.order()); - } - - Actor actor(tr("New Actor"), takeLowestFreeChannel(used)); - actor.setOrder(maxOrder + 1); - // seed an empty profile for the current slots so the slot combo is populated - const QStringList slotNames = m_library->profileSlots(); - if (!slotNames.isEmpty()) - actor.setProfile(slotNames.first(), ActorProfile()); - - const QString id = actor.id(); - m_library->addActor(actor); - rebuildActorTree(id); - m_nameEdit->setFocus(); - m_nameEdit->selectAll(); -} - void ActorSetupPanel::addActors() { if (!m_library) return; bool ok = false; const QString text = QInputDialog::getMultiLineText( - this, tr("Add Multiple Actors"), + this, tr("Add Actors"), tr("One actor per line. Optionally add roles after a tab; pasting Name and " "Role columns from a spreadsheet works."), QString(), &ok); @@ -776,18 +779,64 @@ void ActorSetupPanel::addActors() { rebuildActorTree(firstId); } +void ActorSetupPanel::addRoles() { + if (!m_library) + return; + const QStringList ids = selectedActorIds(); + if (ids.isEmpty()) + return; + + bool ok = false; + QString text = QInputDialog::getMultiLineText( + this, tr("Add Roles"), + tr("Add roles to %n selected actor(s): commas or one per line.", "", ids.size()), + QString(), &ok); + if (!ok) + return; + + const QStringList roles = CastTextParse::parseRoles(text.replace(QLatin1Char('\n'), QLatin1Char(','))); + if (roles.isEmpty()) + return; + + m_updatingUi = true; + for (const QString& id : ids) { + const Actor* a = m_library->actorById(id); + if (!a) + continue; + Actor copy = *a; + copy.setRoles(CastTextParse::mergeRoles(a->roles(), roles)); + m_library->updateActor(id, copy); + } + m_updatingUi = false; + + rebuildActorTree(selectedActorId()); +} + void ActorSetupPanel::removeActor() { - const QString id = selectedActorId(); - if (id.isEmpty() || !m_library) + if (!m_library) + return; + const QStringList ids = selectedActorIds(); + if (ids.isEmpty()) return; - const Actor* a = m_library->actorById(id); - const QString name = a ? a->name() : QString(); - if (QMessageBox::question(this, tr("Remove Actor"), - tr("Remove actor \"%1\"?").arg(name)) != QMessageBox::Yes) + QStringList names; + for (const QString& id : ids) { + if (const Actor* a = m_library->actorById(id)) + names << (a->name().isEmpty() ? tr("(unnamed)") : a->name()); + } + + const QString prompt = + ids.size() == 1 + ? tr("Remove actor \"%1\"?").arg(names.value(0)) + : tr("Remove %n actors?\n\n%1", "", ids.size()).arg(names.join(", ")); + if (QMessageBox::question(this, tr("Remove Actors"), prompt) != QMessageBox::Yes) return; - m_library->removeActor(id); + m_updatingUi = true; + for (const QString& id : ids) + m_library->removeActor(id); + m_updatingUi = false; + rebuildActorTree(); } @@ -937,6 +986,20 @@ void ActorSetupPanel::onActiveToggled(bool on) { m_updatingUi = false; } +void ActorSetupPanel::onUseRoleNameToggled(bool on) { + if (m_updatingUi) + return; + const QString id = selectedActorId(); + const Actor* a = m_library ? m_library->actorById(id) : nullptr; + if (!a) + return; + Actor copy = *a; + copy.setUseRoleName(on); + m_updatingUi = true; + m_library->updateActor(id, copy); + m_updatingUi = false; +} + void ActorSetupPanel::onBackupToggled(bool on) { if (m_updatingUi) return; diff --git a/src/ui/ActorSetupPanel.h b/src/ui/ActorSetupPanel.h index fac0402..b5e2afd 100644 --- a/src/ui/ActorSetupPanel.h +++ b/src/ui/ActorSetupPanel.h @@ -39,8 +39,8 @@ class ActorSetupPanel : public QWidget { private slots: // actor list void onActorSelectionChanged(); - void addActor(); - void addActors(); // bulk add: paste one actor per line + void addActors(); // paste one actor per line + void addRoles(); // append roles to all selected actors void removeActor(); void moveActorUp(); void moveActorDown(); @@ -51,6 +51,7 @@ class ActorSetupPanel : public QWidget { void onRolesChanged(const QString& text); void onChannelChanged(int channel); void onActiveToggled(bool on); + void onUseRoleNameToggled(bool on); void onBackupToggled(bool on); // profile slots @@ -79,6 +80,7 @@ class ActorSetupPanel : public QWidget { void updateButtonStates(); [[nodiscard]] QString selectedActorId() const; + [[nodiscard]] QStringList selectedActorIds() const; [[nodiscard]] int channelCount() const; // lowest channel not in used, capped at channelCount(); inserts the result [[nodiscard]] int takeLowestFreeChannel(QSet& used) const; @@ -92,8 +94,8 @@ class ActorSetupPanel : public QWidget { // actor list QTreeWidget* m_actorTree = nullptr; - QPushButton* m_addActorBtn = nullptr; QPushButton* m_addActorsBtn = nullptr; + QPushButton* m_addRolesBtn = nullptr; QPushButton* m_removeActorBtn = nullptr; QPushButton* m_moveUpBtn = nullptr; QPushButton* m_moveDownBtn = nullptr; @@ -112,6 +114,7 @@ class ActorSetupPanel : public QWidget { QLineEdit* m_rolesEdit = nullptr; QSpinBox* m_channelSpin = nullptr; QCheckBox* m_activeCheck = nullptr; + QCheckBox* m_useRoleCheck = nullptr; QCheckBox* m_backupCheck = nullptr; QComboBox* m_slotCombo = nullptr; diff --git a/src/ui/CastTextParse.h b/src/ui/CastTextParse.h index 5c9d1ab..569a7ae 100644 --- a/src/ui/CastTextParse.h +++ b/src/ui/CastTextParse.h @@ -26,6 +26,17 @@ namespace OpenMix::CastTextParse { return roles; } +// Append `added` roles onto `existing`, skipping case-insensitive duplicates. +[[nodiscard]] inline QStringList mergeRoles(const QStringList& existing, + const QStringList& added) { + QStringList merged = existing; + for (const QString& role : added) { + if (!merged.contains(role, Qt::CaseInsensitive)) + merged.append(role); + } + return merged; +} + struct CastLine { QString name; QStringList roles; diff --git a/src/ui/ChannelStripPanel.cpp b/src/ui/ChannelStripPanel.cpp index 27dc103..b809a9f 100644 --- a/src/ui/ChannelStripPanel.cpp +++ b/src/ui/ChannelStripPanel.cpp @@ -55,7 +55,8 @@ void ChannelStripPanel::rebuild() { auto* tile = new QLabel(this); tile->setAlignment(Qt::AlignCenter); tile->setFixedSize(84, 44); - const QString name = actor.name().isEmpty() ? tr("ch %1").arg(channel) : actor.name(); + const QString name = + actor.displayName().isEmpty() ? tr("ch %1").arg(channel) : actor.displayName(); tile->setText(QString("%1
%2").arg(channel).arg(name.toHtmlEscaped())); const int state = m_app->channelMonitor() ? static_cast(m_app->channelMonitor()->channelState(channel)) diff --git a/src/ui/ChannelUtilizationDialog.cpp b/src/ui/ChannelUtilizationDialog.cpp index bcbc48b..915e9f3 100644 --- a/src/ui/ChannelUtilizationDialog.cpp +++ b/src/ui/ChannelUtilizationDialog.cpp @@ -45,7 +45,7 @@ ChannelUtilizationDialog::ChannelUtilizationDialog(Application* app, QWidget* pa QString actorName; if (actors) { if (const Actor* a = actors->actorForChannel(u.channel)) - actorName = a->name(); + actorName = a->displayName(); } table->setItem(row, 1, new QTableWidgetItem(actorName)); diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 5bd6c53..0954193 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -231,7 +231,6 @@ void CueEditor::setupUi() { outerLayout->setContentsMargins(0, 0, 0, 0); outerLayout->addWidget(scroll); - // connect signals connect(m_numberSpin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &CueEditor::onNumberChanged); connect(m_nameEdit, &QLineEdit::textChanged, this, &CueEditor::onNameChanged); @@ -737,13 +736,11 @@ void CueEditor::updateDCAAssignInfo() { for (int ch : channels) { const Actor* actor = m_actorLibrary ? m_actorLibrary->actorForChannel(ch) : nullptr; if (actor) { - QString role = actor->matchedRole(dcaLabel); - if (role.isEmpty()) - role = actor->primaryRole(); - if (!role.isEmpty()) - parts << tr("Ch %1 %2 (%3)").arg(ch).arg(actor->name(), role); + const QString secondary = actor->secondaryName(actor->matchedRole(dcaLabel)); + if (!secondary.isEmpty()) + parts << tr("Ch %1 %2 (%3)").arg(ch).arg(actor->displayName(), secondary); else - parts << tr("Ch %1 %2").arg(ch).arg(actor->name()); + parts << tr("Ch %1 %2").arg(ch).arg(actor->displayName()); } else { parts << tr("Ch %1").arg(ch); } @@ -780,9 +777,10 @@ void CueEditor::rebuildChannelTable() { chItem->setFlags(Qt::ItemIsEnabled); m_channelTable->setItem(row, 0, chItem); - QString display = a.name().isEmpty() ? tr("(unnamed)") : a.name(); - if (!a.roles().isEmpty()) - display = tr("%1 (%2)").arg(display, a.rolesDisplay()); + QString display = a.displayName().isEmpty() ? tr("(unnamed)") : a.displayName(); + const QString secondary = a.useRoleName() ? a.name() : a.rolesDisplay(); + if (!secondary.isEmpty() && secondary.compare(display, Qt::CaseInsensitive) != 0) + display = tr("%1 (%2)").arg(display, secondary); auto* nameItem = new QTableWidgetItem(display); nameItem->setFlags(Qt::ItemIsEnabled); m_channelTable->setItem(row, 1, nameItem); diff --git a/src/ui/CueItemDelegates.cpp b/src/ui/CueItemDelegates.cpp index 6d1c3df..bf7719e 100644 --- a/src/ui/CueItemDelegates.cpp +++ b/src/ui/CueItemDelegates.cpp @@ -17,6 +17,18 @@ 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 +void fitEditorToCell(QWidget* editor) { + editor->setStyleSheet(QStringLiteral( + "QLineEdit, QComboBox { border: none; border-radius: 0; padding: 0 4px; " + "min-height: 0; }")); +} + +} // namespace + CueNumberDelegate::CueNumberDelegate(CueList* cueList, QObject* parent) : QStyledItemDelegate(parent), m_cueList(cueList) {} @@ -42,6 +54,7 @@ QWidget* CueNumberDelegate::createEditor(QWidget* parent, [[maybe_unused]] const QLineEdit* editor = new QLineEdit(parent); editor->setFrame(false); + fitEditorToCell(editor); editor->setFocusPolicy(Qt::StrongFocus); QDoubleValidator* validator = new QDoubleValidator(0.0, 9999.9, 1, editor); validator->setNotation(QDoubleValidator::StandardNotation); @@ -193,6 +206,7 @@ QWidget* CueTypeDelegate::createEditor(QWidget* parent, [[maybe_unused]] const Q QComboBox* editor = new QComboBox(parent); editor->setFrame(false); + fitEditorToCell(editor); editor->setFocusPolicy(Qt::StrongFocus); // add all cue types @@ -283,6 +297,7 @@ QWidget* DCAAssignDelegate::createEditor(QWidget* parent, QLineEdit* editor = new QLineEdit(parent); editor->setFrame(false); + fitEditorToCell(editor); editor->setFocusPolicy(Qt::StrongFocus); editor->setPlaceholderText(tr("Role, actor, or label")); if (m_library) { @@ -354,6 +369,7 @@ QWidget* CueTextDelegate::createEditor(QWidget* parent, [[maybe_unused]] const Q QLineEdit* editor = new QLineEdit(parent); editor->setFrame(false); + fitEditorToCell(editor); editor->setFocusPolicy(Qt::StrongFocus); editor->installEventFilter(const_cast(this)); diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index cf1fc18..63c4600 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -52,7 +52,6 @@ CueListView::CueListView(Application* app, QWidget* parent) : QWidget(parent), m // connect model signals for undo integration connect(m_model, &CueTableModel::cueReordered, this, &CueListView::onCueReordered); - // install event filter m_tableView->installEventFilter(this); m_tableView->viewport()->installEventFilter(this); } @@ -299,7 +298,6 @@ void CueListView::setStandbyCueHighlight(int index) { } void CueListView::refreshAll() { - // update filter options m_filterBar->updateFilterOptions(); } @@ -318,7 +316,6 @@ void CueListView::addNewCue() { double nextNum = cueList->nextCueNumber(); Cue cue(nextNum); - // push to undo stack m_app->undoStack()->push(new AddCueCommand(cueList, cue)); // manually add cue since undo command's first redo is skipped @@ -342,7 +339,6 @@ void CueListView::deleteSelectedCue() { CueList* cueList = m_app->show()->cueList(); - // push to undo stack m_app->undoStack()->push(new RemoveCueCommand(cueList, idx)); // manually remove since undo command's first redo is skipped @@ -364,7 +360,6 @@ void CueListView::duplicateSelectedCue() { duplicate.regenerateId(); duplicate.setNumber(cueList->nextCueNumber()); - // push to undo stack m_app->undoStack()->push(new AddCueCommand(cueList, duplicate)); // manually add cue diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 974ebc2..0749620 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -93,12 +93,11 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { m_actorLibrary ? m_actorLibrary->resolveActor(*ov.label) : nullptr; if (actor) { // prefer the role the label matched, else the primary - QString actorRole = actor->matchedRole(*ov.label); - if (actorRole.isEmpty()) - actorRole = actor->primaryRole(); - return actorRole.isEmpty() - ? actor->name() - : tr("%1 (%2)").arg(actor->name(), actorRole); + const QString secondary = + actor->secondaryName(actor->matchedRole(*ov.label)); + return secondary.isEmpty() + ? actor->displayName() + : tr("%1 (%2)").arg(actor->displayName(), secondary); } return *ov.label; } @@ -243,14 +242,13 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { const Actor* actor = m_actorLibrary ? m_actorLibrary->actorForChannel(ch) : nullptr; if (actor) { - QString actorRole = actor->matchedRole(dcaLabel); - if (actorRole.isEmpty()) - actorRole = actor->primaryRole(); - members << (actorRole.isEmpty() - ? tr("Ch %1 %2").arg(ch).arg(actor->name()) + const QString secondary = + actor->secondaryName(actor->matchedRole(dcaLabel)); + members << (secondary.isEmpty() + ? tr("Ch %1 %2").arg(ch).arg(actor->displayName()) : tr("Ch %1 %2 (%3)") .arg(ch) - .arg(actor->name(), actorRole)); + .arg(actor->displayName(), secondary)); } else { members << tr("Ch %1").arg(ch); } diff --git a/src/ui/DCAMappingPanel.cpp b/src/ui/DCAMappingPanel.cpp index bb37751..f7527f8 100644 --- a/src/ui/DCAMappingPanel.cpp +++ b/src/ui/DCAMappingPanel.cpp @@ -5,6 +5,7 @@ #include "core/Actor.h" #include "core/ActorProfileLibrary.h" #include "core/Cue.h" +#include "core/CueList.h" #include "core/DCAMapping.h" #include "core/ShortcutManager.h" #include "core/Show.h" @@ -16,6 +17,8 @@ #include #include #include +#include +#include #include #include #include @@ -40,7 +43,24 @@ class NoScrollComboBox : public QComboBox { public: using QComboBox::QComboBox; + // shorter closed-state text; the popup keeps the full item text + static constexpr int CompactTextRole = Qt::UserRole + 1; + protected: + void paintEvent(QPaintEvent* event) override { + const QVariant compact = currentData(CompactTextRole); + if (!compact.isValid()) { + QComboBox::paintEvent(event); + return; + } + QStylePainter painter(this); + QStyleOptionComboBox opt; + initStyleOption(&opt); + opt.currentText = compact.toString(); + painter.drawComplexControl(QStyle::CC_ComboBox, opt); + painter.drawControl(QStyle::CE_ComboBoxLabel, opt); + } + void wheelEvent(QWheelEvent* event) override { event->ignore(); } void showPopup() override { @@ -78,7 +98,6 @@ DCAMappingPanel::DCAMappingPanel(Application* app, QWidget* parent) // console-type selection changes the DCA/channel/bus counts shown here connect(m_app, &Application::dcaCountChanged, this, &DCAMappingPanel::refresh); - // get mapping from show m_mapping = m_app->show()->dcaMapping(); if (m_mapping) { connect(m_mapping, &DCAMapping::mappingCleared, this, &DCAMappingPanel::refresh); @@ -87,6 +106,10 @@ DCAMappingPanel::DCAMappingPanel(Application* app, QWidget* parent) // actor renames/role edits change the channel labels shown here connect(m_app->show()->actorProfileLibrary(), &ActorProfileLibrary::changed, this, &DCAMappingPanel::onActorsChanged); + + // a cleared cue list destroys the cue this panel points at + connect(m_app->show()->cueList(), &CueList::listCleared, this, + &DCAMappingPanel::clearCurrentCue); } refresh(); @@ -335,6 +358,9 @@ void DCAMappingPanel::createChannelSection() { m_channelLayout->addWidget(nameContainer, row, colOffset); NoScrollComboBox* combo = new NoScrollComboBox(m_channelGroup); + // item texts vary with cue labels; keep the closed combo width stable + combo->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); + combo->setMinimumContentsLength(14); combo->addItem(tr("None"), -1); for (int d = 1; d <= m_dcaCount; ++d) { combo->addItem(tr("DCA %1").arg(d), d); @@ -401,6 +427,9 @@ void DCAMappingPanel::createBusSection() { m_busLayout->addWidget(nameContainer, row, colOffset); NoScrollComboBox* combo = new NoScrollComboBox(m_busGroup); + // item texts vary with cue labels; keep the closed combo width stable + combo->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); + combo->setMinimumContentsLength(14); combo->addItem(tr("None"), -1); for (int d = 1; d <= m_dcaCount; ++d) { combo->addItem(tr("DCA %1").arg(d), d); @@ -547,7 +576,7 @@ void DCAMappingPanel::updateComboItemStates() { const QString display = channelDisplayName(ch); if (dca > 0) { - label->setText(tr("%1 [%2]").arg(display).arg(dca)); + label->setText(display); label->setStyleSheet(assignedStyle); label->setToolTip(tr("%1: assigned to DCA %2, locked from other DCAs; " "double-click to rename") @@ -567,7 +596,7 @@ void DCAMappingPanel::updateComboItemStates() { QLabel* label = m_busLabels[bus - 1]; if (dca > 0) { - label->setText(tr("%1 [%2]").arg(busDisplayName(bus)).arg(dca)); + label->setText(busDisplayName(bus)); label->setStyleSheet(assignedStyle); label->setToolTip(tr("Assigned to DCA %1; double-click to rename").arg(dca)); } else { @@ -577,20 +606,27 @@ void DCAMappingPanel::updateComboItemStates() { } } - // update combo box items w/ assignment counts + QStringList itemTexts; + QStringList compactTexts; + for (int d = 1; d <= m_dcaCount; ++d) { + const int total = channelCounts[d] + busCounts[d]; + const QString name = dcaDisplayName(d); + const QString plain = tr("DCA %1").arg(d); + 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) { for (int d = 1; d <= m_dcaCount; ++d) { - int total = channelCounts[d] + busCounts[d]; - QString text = total > 0 ? tr("DCA %1 (%2)").arg(d).arg(total) : tr("DCA %1").arg(d); - combo->setItemText(d, text); + combo->setItemText(d, itemTexts[d - 1]); + combo->setItemData(d, compactTexts[d - 1], NoScrollComboBox::CompactTextRole); } } for (QComboBox* combo : m_busCombos) { for (int d = 1; d <= m_dcaCount; ++d) { - int total = channelCounts[d] + busCounts[d]; - QString text = total > 0 ? tr("DCA %1 (%2)").arg(d).arg(total) : tr("DCA %1").arg(d); - combo->setItemText(d, text); + combo->setItemText(d, itemTexts[d - 1]); + combo->setItemData(d, compactTexts[d - 1], NoScrollComboBox::CompactTextRole); } } } @@ -959,13 +995,43 @@ QString DCAMappingPanel::channelDisplayName(int channel) const { const ActorProfileLibrary* library = (m_app && m_app->show()) ? m_app->show()->actorProfileLibrary() : nullptr; const Actor* actor = library ? library->actorForChannel(channel) : nullptr; - if (actor && !actor->primaryRole().isEmpty()) - return tr("Ch %1: %2 (%3)").arg(channel).arg(actor->name(), actor->primaryRole()); - if (actor && !actor->name().isEmpty()) - return tr("Ch %1: %2").arg(channel).arg(actor->name()); + if (actor && !actor->displayName().isEmpty()) { + const QString secondary = actor->secondaryName(); + if (!secondary.isEmpty()) + return tr("Ch %1: %2 (%3)").arg(channel).arg(actor->displayName(), secondary); + return tr("Ch %1: %2").arg(channel).arg(actor->displayName()); + } return tr("Ch %1").arg(channel); } +QString DCAMappingPanel::dcaDisplayName(int dca) const { + if (m_currentCue) { + const QString label = m_currentCue->dcaOverride(dca).label.value_or(QString()); + if (!label.isEmpty()) + return tr("DCA %1: %2").arg(dca).arg(label); + } + return tr("DCA %1").arg(dca); +} + +QString DCAMappingPanel::overviewMemberName(int channel, const QString& dcaLabel) const { + // drop the part of the channel's label that just repeats the DCA name + const ActorProfileLibrary* library = + (m_app && m_app->show()) ? m_app->show()->actorProfileLibrary() : nullptr; + const Actor* actor = library ? library->actorForChannel(channel) : nullptr; + if (!actor || actor->displayName().isEmpty() || dcaLabel.isEmpty()) + return channelDisplayName(channel); + + const QString primary = actor->displayName(); + const QString secondary = actor->secondaryName(actor->matchedRole(dcaLabel)); + if (primary.compare(dcaLabel, Qt::CaseInsensitive) == 0) { + return secondary.isEmpty() ? tr("Ch %1").arg(channel) + : tr("Ch %1 (%2)").arg(channel).arg(secondary); + } + if (!secondary.isEmpty() && secondary.compare(dcaLabel, Qt::CaseInsensitive) == 0) + return tr("Ch %1: %2").arg(channel).arg(primary); + return channelDisplayName(channel); +} + void DCAMappingPanel::updateDcaOverview() { if (!m_mapping) return; @@ -982,19 +1048,18 @@ void DCAMappingPanel::updateDcaOverview() { QLabel* title = m_dcaOverviewTitles[d - 1]; QLabel* members = m_dcaOverviewMembers[d - 1]; - QString titleText = tr("DCA %1").arg(d); - if (m_currentCue) { - const QString label = m_currentCue->dcaOverride(d).label.value_or(QString()); - if (!label.isEmpty()) - titleText += QString(": %1").arg(label); - } - title->setText(titleText); + title->setText(dcaDisplayName(d)); + const QString dcaLabel = + m_currentCue ? m_currentCue->dcaOverride(d).label.value_or(QString()) : QString(); QStringList parts; for (int ch : channelMap.value(d)) - parts << channelDisplayName(ch); - for (int bus : busMap.value(d)) - parts << busDisplayName(bus); + parts << overviewMemberName(ch, dcaLabel); + for (int bus : busMap.value(d)) { + const QString busName = busDisplayName(bus); + parts << (busName.compare(dcaLabel, Qt::CaseInsensitive) == 0 ? tr("Bus %1").arg(bus) + : busName); + } members->setText(parts.isEmpty() ? tr("(empty)") : parts.join(", ")); } } diff --git a/src/ui/DCAMappingPanel.h b/src/ui/DCAMappingPanel.h index 697394d..53d2f18 100644 --- a/src/ui/DCAMappingPanel.h +++ b/src/ui/DCAMappingPanel.h @@ -79,6 +79,8 @@ class DCAMappingPanel : public QWidget { void updateDcaOverview(); QString busDisplayName(int bus) const; QString channelDisplayName(int channel) const; + QString dcaDisplayName(int dca) const; + QString overviewMemberName(int channel, const QString& dcaLabel) const; Application* m_app; DCAMapping* m_mapping; diff --git a/src/ui/EnsemblePanel.cpp b/src/ui/EnsemblePanel.cpp index 518859b..3e3841b 100644 --- a/src/ui/EnsemblePanel.cpp +++ b/src/ui/EnsemblePanel.cpp @@ -76,7 +76,6 @@ void EnsemblePanel::setupUi() { mainLayout->setContentsMargins(8, 8, 8, 8); mainLayout->setSpacing(8); - // toolbar QHBoxLayout* toolbarLayout = new QHBoxLayout(); m_addButton = new QPushButton(Icons::listAdd(), tr("Add Ensemble"), this); m_addButton->setToolTip(tr("Create a new ensemble")); @@ -91,12 +90,10 @@ void EnsemblePanel::setupUi() { toolbarLayout->addStretch(); mainLayout->addLayout(toolbarLayout); - // ensemble list m_list = new QListWidget(this); connect(m_list, &QListWidget::itemSelectionChanged, this, &EnsemblePanel::onSelectionChanged); mainLayout->addWidget(m_list, 1); - // editor QGroupBox* editorGroup = new QGroupBox(tr("Ensemble"), this); QFormLayout* form = new QFormLayout(editorGroup); diff --git a/src/ui/KeyboardShortcutsDialog.cpp b/src/ui/KeyboardShortcutsDialog.cpp index 2a19bfa..efe836b 100644 --- a/src/ui/KeyboardShortcutsDialog.cpp +++ b/src/ui/KeyboardShortcutsDialog.cpp @@ -23,7 +23,6 @@ KeyboardShortcutsDialog::KeyboardShortcutsDialog(ShortcutManager* manager, QWidg void KeyboardShortcutsDialog::setupUi() { QVBoxLayout* mainLayout = new QVBoxLayout(this); - // filter bar QHBoxLayout* filterLayout = new QHBoxLayout(); QLabel* filterLabel = new QLabel(tr("Filter:"), this); m_filterEdit = new QLineEdit(this); @@ -61,7 +60,6 @@ void KeyboardShortcutsDialog::setupUi() { filterLayout->addWidget(m_filterEdit); mainLayout->addLayout(filterLayout); - // shortcuts tree m_shortcutsTree = new QTreeWidget(this); m_shortcutsTree->setHeaderLabels({tr("Action"), tr("Shortcut"), tr("Default")}); m_shortcutsTree->setColumnCount(3); @@ -77,7 +75,6 @@ void KeyboardShortcutsDialog::setupUi() { mainLayout->addWidget(m_shortcutsTree); - // shortcut editor section QGroupBox* editorGroup = new QGroupBox(tr("Edit Shortcut"), this); QVBoxLayout* editorLayout = new QVBoxLayout(editorGroup); @@ -121,7 +118,6 @@ void KeyboardShortcutsDialog::setupUi() { mainLayout->addWidget(editorGroup); - // dialog buttons QHBoxLayout* buttonLayout = new QHBoxLayout(); m_resetAllButton = new QPushButton(tr("Reset All to Defaults"), this); @@ -169,7 +165,6 @@ void KeyboardShortcutsDialog::populateShortcutTree() { categories[category] = categoryItem; } - // create action item QTreeWidgetItem* item = new QTreeWidgetItem(categories[category]); item->setText(0, info.displayName); item->setData(0, Qt::UserRole, info.id); @@ -254,15 +249,12 @@ void KeyboardShortcutsDialog::onShortcutEditingFinished() { QKeySequence newShortcut = m_shortcutEdit->keySequence(); - // check for conflict if (checkForConflict(m_currentActionId, newShortcut)) { return; // conflict detected } - // store pending change m_pendingChanges[m_currentActionId] = newShortcut; - // update tree display QList selected = m_shortcutsTree->selectedItems(); if (!selected.isEmpty()) { updateShortcutDisplay(selected.first(), newShortcut); @@ -367,7 +359,6 @@ void KeyboardShortcutsDialog::onResetAllClicked() { m_pendingChanges[info.id] = info.defaultShortcut; } - // refresh the tree populateShortcutTree(); // clear selection and editor @@ -407,7 +398,6 @@ void KeyboardShortcutsDialog::accept() { m_manager->setShortcut(id, shortcut); } - // save to settings m_manager->saveToSettings(); QDialog::accept(); diff --git a/src/ui/LogViewerDialog.cpp b/src/ui/LogViewerDialog.cpp index 89f0aa9..fa613c2 100644 --- a/src/ui/LogViewerDialog.cpp +++ b/src/ui/LogViewerDialog.cpp @@ -32,11 +32,9 @@ void LogViewerDialog::setupUi() { mainLayout->setSpacing(12); mainLayout->setContentsMargins(16, 16, 16, 16); - // filter bar QHBoxLayout* filterLayout = new QHBoxLayout(); filterLayout->setSpacing(12); - // level filter QLabel* levelLabel = new QLabel(tr("Level:"), this); m_levelCombo = new QComboBox(this); m_levelCombo->addItem(tr("Debug"), static_cast(LogLevel::Debug)); @@ -49,7 +47,6 @@ void LogViewerDialog::setupUi() { connect(m_levelCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &LogViewerDialog::onLevelFilterChanged); - // source filter QLabel* sourceLabel = new QLabel(tr("Source:"), this); m_sourceCombo = new QComboBox(this); m_sourceCombo->addItem(tr("All Sources"), -1); @@ -65,14 +62,12 @@ void LogViewerDialog::setupUi() { connect(m_sourceCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &LogViewerDialog::onSourceFilterChanged); - // search m_searchEdit = new QLineEdit(this); m_searchEdit->setPlaceholderText(tr("Search...")); m_searchEdit->setClearButtonEnabled(true); m_searchEdit->setMinimumWidth(200); connect(m_searchEdit, &QLineEdit::textChanged, this, &LogViewerDialog::onSearchTextChanged); - // auto scroll m_autoScrollCheck = new QCheckBox(tr("Auto-scroll"), this); m_autoScrollCheck->setChecked(true); connect(m_autoScrollCheck, &QCheckBox::checkStateChanged, this, @@ -87,7 +82,6 @@ void LogViewerDialog::setupUi() { mainLayout->addLayout(filterLayout); - // log view m_logView = new QListView(this); m_logView->setModel(m_model); m_logView->setItemDelegate(m_delegate); @@ -99,7 +93,6 @@ void LogViewerDialog::setupUi() { mainLayout->addWidget(m_logView, 1); - // button bar QHBoxLayout* buttonLayout = new QHBoxLayout(); buttonLayout->setSpacing(8); diff --git a/src/ui/MacroPreviewWidget.cpp b/src/ui/MacroPreviewWidget.cpp index 772fddb..b1f3801 100644 --- a/src/ui/MacroPreviewWidget.cpp +++ b/src/ui/MacroPreviewWidget.cpp @@ -13,7 +13,6 @@ MacroPreviewWidget::MacroPreviewWidget(QWidget* parent) : QWidget(parent) { m_layout->setContentsMargins(0, 0, 0, 0); m_layout->setSpacing(4); - // header label m_headerLabel = new QLabel(this); m_headerLabel->setProperty("role", "header"); m_layout->addWidget(m_headerLabel); @@ -55,7 +54,6 @@ void MacroPreviewWidget::setMacroCue(const Cue* cue, const CueList* cueList) { return; } - // show the tree widget m_treeWidget->setVisible(true); m_emptyLabel->setVisible(false); diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3ca968f..dade72a 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -898,15 +898,17 @@ void MainWindow::connectSignals() { connect(m_cueEditor, &CueEditor::cueModified, [this]() { m_cueListView->refreshCurrentCue(); - // editor edits can change the cue's DCA mapping/labels; keep the DCA - // panel showing the same cue in step (skip when hidden: rebuild is - // per-keystroke while typing in the editor) - if (m_dcaMappingPopOut->isVisible()) { - CueList* cueList = m_app->show()->cueList(); - const int index = m_cueListView->selectedCueIndex(); - if (index >= 0 && index < cueList->count()) - m_dcaMappingPanel->setCurrentCue(&(*cueList)[index]); - } + }); + + // repaint the DCA panel when the cue it shows changes; skipped while + // hidden since the rebuild runs per-keystroke + connect(m_app->show()->cueList(), &CueList::cueUpdated, this, [this](int index) { + if (!m_dcaMappingPopOut->isVisible()) + return; + if (index < 0 || index != m_cueListView->selectedCueIndex()) + return; + CueList* cueList = m_app->show()->cueList(); + m_dcaMappingPanel->setCurrentCue(&(*cueList)[index]); }); connect(m_app->playbackEngine(), &PlaybackEngine::goLockout, this, &MainWindow::onGoLockout); @@ -1075,25 +1077,35 @@ void MainWindow::importTmixShow() { updateTitle(); updateStatusBar(); - // explain how TheatreMix concepts landed in OpenMix + QString namesLine; + if (summary.channelNames > 0) { + namesLine = tr("
  • The file's channel names (from its show setup) became " + "OpenMix actor names; OpenMix writes these to the console's " + "scribble strips. Edit them in Actor Setup (F9).
  • "); + } QString rolesLine; if (summary.rolesInferred > 0) { rolesLine = tr("
  • %1 role(s) were inferred from cue DCA labels; review them " "in Actor Setup (F9).
  • ") .arg(summary.rolesInferred); } + QString slotsLine; + if (!summary.profileSlots.isEmpty()) { + slotsLine = tr("
  • Additional channel profiles (%1) became voice " + "profile slots: show-wide voice categories shared by every actor, " + "not per-mic names. Each actor stores their own EQ/dynamics per slot " + "in Actor Setup.
  • ") + .arg(summary.profileSlots.join(", ")); + } const QString html = tr("

    Imported %1 actors, %2 cues, %3 positions and " "%4 ensembles.

    " - "

    How TheatreMix concepts map onto OpenMix:

    " + "

    How the imported show maps onto OpenMix:

    " "
      " - "
    • TheatreMix actors are OpenMix actors. Each also has a new Roles " + "
    • Imported actors are OpenMix actors. Each also has a new Roles " "(characters) field; typing one of their roles or the actor name into a cue's " "DCA slot assigns their channel to that DCA.
    • " - "%5" - "
    • TheatreMix profiles (%6) became voice profile slots: show-wide " - "voice categories shared by every actor, not per-mic names. Each actor stores " - "their own EQ/dynamics per slot in Actor Setup.
    • " + "%5%6%7" "
    • Cue DCA labels and channel lists became per-cue DCA label overrides " "and cue-specific DCA mappings; see the DCA Mapping view (F5).
    • " "
    ") @@ -1101,10 +1113,10 @@ void MainWindow::importTmixShow() { .arg(summary.cues) .arg(summary.positions) .arg(summary.ensembles) + .arg(namesLine) .arg(rolesLine) - .arg(summary.profileSlots.isEmpty() ? tr("none found") - : summary.profileSlots.join(", ")); - HelpDialog dialog(tr("Imported from TheatreMix"), html, this); + .arg(slotsLine); + HelpDialog dialog(tr("Show File Imported"), html, this); dialog.exec(); } @@ -1226,6 +1238,9 @@ void MainWindow::showWelcomeDialog() { updateRecentProjectsMenu(); break; } + case WelcomeDialog::Choice::Import: + importTmixShow(); + break; case WelcomeDialog::Choice::None: break; } @@ -1360,9 +1375,9 @@ void MainWindow::updateStatusBar() { int count = m_app->show()->cueList()->count(); m_cueStatusLabel->setText(tr("%n cue(s)", "", count)); - int currentIdx = m_app->playbackEngine()->currentCueIndex(); - if (currentIdx >= 0) { - const Cue* cue = m_app->playbackEngine()->currentCue(); + // key off the bounds-checked cue pointers, not the raw indices: during a + // bulk list clear the indices can be momentarily stale + if (const Cue* cue = m_app->playbackEngine()->currentCue()) { m_currentCueLabel->setText(tr("Current: %1 - %2") .arg(cue->number(), 0, 'f', 1) .arg(cue->name().isEmpty() ? tr("(unnamed)") : cue->name())); @@ -1370,9 +1385,7 @@ void MainWindow::updateStatusBar() { m_currentCueLabel->setText(tr("Current: --")); } - int standbyIdx = m_app->playbackEngine()->standbyCueIndex(); - if (standbyIdx >= 0) { - const Cue* cue = m_app->playbackEngine()->standbyCue(); + if (const Cue* cue = m_app->playbackEngine()->standbyCue()) { m_nextCueLabel->setText(tr("Next: %1 - %2") .arg(cue->number(), 0, 'f', 1) .arg(cue->name().isEmpty() ? tr("(unnamed)") : cue->name())); @@ -1543,8 +1556,8 @@ void MainWindow::showQuickStart() { "Edit → Lock Editing prevents accidental edits during a performance." "" "

    Already have a show file? File → Import Show File… reads a " - ".tmix database directly: TheatreMix actors become actors (roles are " - "inferred from cue DCA labels where unambiguous), TheatreMix profiles become " + ".tmix database directly: its channel names become actor names (roles are " + "inferred from cue DCA labels where unambiguous), additional channel profiles become " "show-wide voice profile slots, and cue DCA labels/channels become per-cue DCA " "overrides and mappings.

    "); HelpDialog dialog(tr("Quick Start"), html, this); diff --git a/src/ui/MidiConfigDialog.cpp b/src/ui/MidiConfigDialog.cpp index 9ee54ca..c9e7218 100644 --- a/src/ui/MidiConfigDialog.cpp +++ b/src/ui/MidiConfigDialog.cpp @@ -18,7 +18,7 @@ namespace OpenMix { MidiConfigDialog::MidiConfigDialog(MidiInputManager* manager, int channelCount, QWidget* parent) : QDialog(parent), m_manager(manager), m_channelCount(qMax(1, channelCount)) { setWindowTitle(tr("MIDI Controller")); - setMinimumSize(600, 620); + setMinimumWidth(600); WindowSizing::widenOnShow(this); // load current settings @@ -52,7 +52,6 @@ void MidiConfigDialog::setupUi() { Theme::Spacing::L); mainLayout->setSpacing(Theme::Spacing::M); - // device section QGroupBox* deviceGroup = new QGroupBox(tr("MIDI Device"), this); QVBoxLayout* deviceLayout = new QVBoxLayout(deviceGroup); deviceLayout->setSpacing(Theme::Spacing::S); @@ -93,7 +92,6 @@ void MidiConfigDialog::setupUi() { mainLayout->addWidget(deviceGroup); - // action mappings section QGroupBox* mappingsGroup = new QGroupBox(tr("Action Mappings"), this); QVBoxLayout* mappingsLayout = new QVBoxLayout(mappingsGroup); mappingsLayout->setSpacing(Theme::Spacing::S); @@ -109,10 +107,12 @@ void MidiConfigDialog::setupUi() { m_mappingsTable->verticalHeader()->setVisible(false); m_mappingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); m_mappingsTable->setMinimumHeight(140); + m_mappingsTable->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); mappingsLayout->addWidget(m_mappingsTable); QHBoxLayout* mappingButtonsLayout = new QHBoxLayout(); mappingButtonsLayout->setSpacing(Theme::Spacing::S); + mappingButtonsLayout->addStretch(); m_addMappingButton = new QPushButton(Icons::listAdd(), tr("Add Mapping"), this); connect(m_addMappingButton, &QPushButton::clicked, this, &MidiConfigDialog::onAddMappingClicked); @@ -124,13 +124,10 @@ void MidiConfigDialog::setupUi() { "selected, its trigger is replaced; otherwise a new mapping is added.")); connect(m_midiLearnButton, &QPushButton::clicked, this, &MidiConfigDialog::onMidiLearnClicked); mappingButtonsLayout->addWidget(m_midiLearnButton); - - mappingButtonsLayout->addStretch(); mappingsLayout->addLayout(mappingButtonsLayout); mainLayout->addWidget(mappingsGroup); - // channel mute buttons section QGroupBox* mutesGroup = new QGroupBox(tr("Channel Mute Buttons"), this); QVBoxLayout* mutesLayout = new QVBoxLayout(mutesGroup); mutesLayout->setSpacing(Theme::Spacing::S); @@ -151,10 +148,12 @@ void MidiConfigDialog::setupUi() { m_mutesTable->verticalHeader()->setVisible(false); m_mutesTable->setEditTriggers(QAbstractItemView::NoEditTriggers); m_mutesTable->setMinimumHeight(140); + m_mutesTable->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); mutesLayout->addWidget(m_mutesTable); QHBoxLayout* muteButtonsLayout = new QHBoxLayout(); muteButtonsLayout->setSpacing(Theme::Spacing::S); + muteButtonsLayout->addStretch(); m_addMuteButton = new QPushButton(Icons::listAdd(), tr("Add Assignment"), this); connect(m_addMuteButton, &QPushButton::clicked, this, &MidiConfigDialog::onAddMuteClicked); muteButtonsLayout->addWidget(m_addMuteButton); @@ -165,8 +164,6 @@ void MidiConfigDialog::setupUi() { "selected, its trigger is replaced; otherwise a new assignment is added.")); connect(m_muteLearnButton, &QPushButton::clicked, this, &MidiConfigDialog::onMuteLearnClicked); muteButtonsLayout->addWidget(m_muteLearnButton); - - muteButtonsLayout->addStretch(); mutesLayout->addLayout(muteButtonsLayout); mainLayout->addWidget(mutesGroup); @@ -240,7 +237,6 @@ void MidiConfigDialog::addMappingRow(const MidiMapping& mapping) { int row = m_mappingsTable->rowCount(); m_mappingsTable->insertRow(row); - // trigger column QTableWidgetItem* triggerItem = new QTableWidgetItem(mapping.trigger.toString()); m_mappingsTable->setItem(row, 0, triggerItem); @@ -258,7 +254,6 @@ void MidiConfigDialog::addMappingRow(const MidiMapping& mapping) { }); m_mappingsTable->setCellWidget(row, 1, actionCombo); - // remove button auto* removeBtn = new QToolButton(); removeBtn->setIcon(Icons::listRemove()); removeBtn->setAutoRaise(true); diff --git a/src/ui/TimecodePanel.cpp b/src/ui/TimecodePanel.cpp index 47dcd00..2e418c4 100644 --- a/src/ui/TimecodePanel.cpp +++ b/src/ui/TimecodePanel.cpp @@ -68,7 +68,6 @@ void TimecodePanel::setupUi() { m_liveLabel->setFont(mono); mainLayout->addWidget(m_liveLabel); - // trigger table m_table = new QTableWidget(0, 3, this); m_table->setHorizontalHeaderLabels({tr("Timecode"), tr("Cue"), tr("Enabled")}); m_table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); @@ -117,7 +116,6 @@ void TimecodePanel::setupUi() { mainLayout->addWidget(addGroup); - // remove QHBoxLayout* removeLayout = new QHBoxLayout(); removeLayout->addStretch(); m_removeButton = new QPushButton(Icons::listRemove(), tr("Remove Selected"), this); diff --git a/src/ui/WelcomeDialog.cpp b/src/ui/WelcomeDialog.cpp index 92473aa..346da5f 100644 --- a/src/ui/WelcomeDialog.cpp +++ b/src/ui/WelcomeDialog.cpp @@ -38,7 +38,7 @@ void WelcomeDialog::setupUi() { title->setFont(titleFont); mainLayout->addWidget(title); - QLabel* subtitle = new QLabel(tr("Open a recent show, or start a new one."), this); + QLabel* subtitle = new QLabel(tr("Open a recent show, start a new one, or import a show file."), this); subtitle->setEnabled(false); // muted mainLayout->addWidget(subtitle); mainLayout->addSpacing(16); @@ -55,6 +55,12 @@ void WelcomeDialog::setupUi() { openButton->setMinimumHeight(40); connect(openButton, &QPushButton::clicked, this, &WelcomeDialog::chooseOpen); actions->addWidget(openButton); + + QPushButton* importButton = new QPushButton(tr("Import Show..."), this); + importButton->setMinimumHeight(40); + importButton->setToolTip(tr("Import a .tmix show file")); + connect(importButton, &QPushButton::clicked, this, &WelcomeDialog::chooseImport); + actions->addWidget(importButton); mainLayout->addLayout(actions); mainLayout->addSpacing(16); @@ -107,6 +113,11 @@ void WelcomeDialog::chooseOpen() { accept(); } +void WelcomeDialog::chooseImport() { + m_choice = Choice::Import; + accept(); +} + void WelcomeDialog::chooseRecent() { QListWidgetItem* item = m_recentList->currentItem(); if (!item) diff --git a/src/ui/WelcomeDialog.h b/src/ui/WelcomeDialog.h index 4049e75..d46ca65 100644 --- a/src/ui/WelcomeDialog.h +++ b/src/ui/WelcomeDialog.h @@ -13,7 +13,7 @@ class WelcomeDialog : public QDialog { Q_OBJECT public: - enum class Choice { None, NewShow, OpenShow, OpenRecent }; + enum class Choice { None, NewShow, OpenShow, OpenRecent, Import }; explicit WelcomeDialog(QWidget* parent = nullptr); @@ -26,6 +26,7 @@ class WelcomeDialog : public QDialog { private slots: void chooseNew(); void chooseOpen(); + void chooseImport(); void chooseRecent(); private: diff --git a/tests/test_actor_profiles.cpp b/tests/test_actor_profiles.cpp index 62a6b3f..7987b04 100644 --- a/tests/test_actor_profiles.cpp +++ b/tests/test_actor_profiles.cpp @@ -89,6 +89,35 @@ class TestActorProfiles : public QObject { QCOMPARE(r.roles(), QStringList({"Cosette", "Singer #1"})); } + void actor_useRoleName_roundTripAndDisplayName() { + Actor a("WL01", 17); + a.setRoles({"Valjean"}); + QCOMPARE(a.displayName(), QString("WL01")); + QCOMPARE(a.secondaryName(), QString("Valjean")); + + a.setUseRoleName(true); + QCOMPARE(a.displayName(), QString("Valjean")); + QCOMPARE(a.secondaryName(), QString("WL01")); + + Actor r = Actor::fromJson(a.toJson()); + QVERIFY(r.useRoleName()); + QCOMPARE(r.displayName(), QString("Valjean")); + + // no roles to prefer: falls back to the actor name + Actor bare("Bob", 4); + bare.setUseRoleName(true); + QCOMPARE(bare.displayName(), QString("Bob")); + QVERIFY(bare.secondaryName().isEmpty()); + + // identical name and role never render as "Barry (Barry)" + Actor same("Barry", 1); + same.setRoles({"Barry"}); + QVERIFY(same.secondaryName().isEmpty()); + + // default flag stays out of the file + QVERIFY(!Actor("Alice", 5).toJson().contains("useRoleName")); + } + void actor_emptyRoles_omittedFromJson() { Actor a("Alice", 5); QVERIFY(!a.toJson().contains("roles")); diff --git a/tests/test_cast_text_parse.cpp b/tests/test_cast_text_parse.cpp index b758cea..8319eb4 100644 --- a/tests/test_cast_text_parse.cpp +++ b/tests/test_cast_text_parse.cpp @@ -19,6 +19,27 @@ class TestCastTextParse : public QObject { QVERIFY(CastTextParse::parseRoles(" , ,, ").isEmpty()); } + // --- mergeRoles --------------------------------------------------------- + void mergeRoles_appendsOnlyNewRoles() { + QCOMPARE(CastTextParse::mergeRoles({"Cosette"}, {"Ensemble", "Swing"}), + QStringList({"Cosette", "Ensemble", "Swing"})); + // case-insensitive duplicates are skipped, existing order preserved + QCOMPARE(CastTextParse::mergeRoles({"Cosette", "Swing"}, {"cosette", "Ensemble"}), + QStringList({"Cosette", "Swing", "Ensemble"})); + QCOMPARE(CastTextParse::mergeRoles({}, {"Ensemble"}), QStringList({"Ensemble"})); + QCOMPARE(CastTextParse::mergeRoles({"Cosette"}, {}), QStringList({"Cosette"})); + QVERIFY(CastTextParse::mergeRoles({}, {}).isEmpty()); + } + + void mergeRoles_newlineInputViaParseRoles() { + // the Add Roles dialog accepts commas or one role per line; newlines + // are normalised to commas before parseRoles + QString text = QStringLiteral("Ensemble, Swing\nFactory Girl\n\n swing "); + const QStringList roles = + CastTextParse::parseRoles(text.replace(QLatin1Char('\n'), QLatin1Char(','))); + QCOMPARE(roles, QStringList({"Ensemble", "Swing", "Factory Girl"})); + } + // --- parseCastLines ---------------------------------------------------- void castLines_plainNames() { const auto lines = CastTextParse::parseCastLines("Alice\nBob\nCarol"); diff --git a/tests/test_cue_table_model.cpp b/tests/test_cue_table_model.cpp index da8b82b..dccf023 100644 --- a/tests/test_cue_table_model.cpp +++ b/tests/test_cue_table_model.cpp @@ -93,6 +93,20 @@ class TestCueTableModel : public QObject { QVERIFY(cue.dcaChannelMapping().value(2).contains(9)); } + void setData_dcaCell_emitsCueUpdated_withRow() { + // the DCA mapping panel's live refresh keys off this signal + CueList list; + list.addCue(Cue(1.0, "One")); + list.addCue(Cue(2.0, "Two")); + CueTableModel model(&list); + + QSignalSpy updated(&list, &CueList::cueUpdated); + QVERIFY(model.setData(model.index(1, dcaCol(model, 3)), "Hamlet", Qt::EditRole)); + QCOMPARE(updated.count(), 1); + QCOMPARE(updated.at(0).at(0).toInt(), 1); + QCOMPARE(list.at(1).dcaOverride(3).label, std::optional("Hamlet")); + } + void setData_nonResolvingText_storesLabelOnly() { CueList list; list.addCue(Cue(1.0, "One")); diff --git a/tests/test_scribble.cpp b/tests/test_scribble.cpp index 1ef38e9..32448fd 100644 --- a/tests/test_scribble.cpp +++ b/tests/test_scribble.cpp @@ -73,6 +73,27 @@ class TestScribble : public QObject { QVERIFY(mixer.hasName(5, "Bob")); } + void refreshNames_pushesRoleWhenPreferred() { + ActorProfileLibrary lib; + Actor packed("WL01", 3); + packed.setRoles({"Valjean"}); + packed.setUseRoleName(true); + lib.addActor(packed); + Actor bare("WL02", 5); // prefers role but has none: falls back to name + bare.setUseRoleName(true); + lib.addActor(bare); + + RecordingMixer mixer; + ScribbleController ctl; + ctl.setActorLibrary(&lib); + ctl.setMixer(&mixer); + + mixer.nameCalls.clear(); + ctl.refreshNames(); + QVERIFY(mixer.hasName(3, "Valjean")); + QVERIFY(mixer.hasName(5, "WL02")); + } + void refreshNames_prefersLowestOrderActive() { ActorProfileLibrary lib; Actor lead("Lead", 4); diff --git a/tests/test_show_control.cpp b/tests/test_show_control.cpp index eb390ef..3a5e4c4 100644 --- a/tests/test_show_control.cpp +++ b/tests/test_show_control.cpp @@ -397,6 +397,100 @@ class TestShowControl : public QObject { QCOMPARE(spy.count(), 2); QVERIFY(!engine.checkMode()); } + + // --- list reset (new show / show load) --- + + void listClear_resetsPlaybackIndices() { + CueList cues; + cues.addCue(Cue(1.0, "A")); + cues.addCue(Cue(2.0, "B")); + cues.addCue(Cue(3.0, "C")); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); // standby -> 0 + engine.setMixer(mixer); + engine.go(); // current 0, standby 1 + + QSignalSpy currentSpy(&engine, &PlaybackEngine::currentCueChanged); + QSignalSpy standbySpy(&engine, &PlaybackEngine::standbyCueChanged); + + cues.clear(); + + QCOMPARE(engine.currentCueIndex(), -1); + QCOMPARE(engine.standbyCueIndex(), -1); + QCOMPARE(engine.currentCue(), nullptr); + QCOMPARE(engine.standbyCue(), nullptr); + QCOMPARE(engine.state(), PlaybackState::Stopped); + QCOMPARE(currentSpy.count(), 1); + QCOMPARE(currentSpy.at(0).at(0).toInt(), -1); + QCOMPARE(standbySpy.count(), 1); + QCOMPARE(standbySpy.at(0).at(0).toInt(), -1); + + engine.go(); // no-op on an empty list + QCOMPARE(engine.currentCueIndex(), -1); + QCOMPARE(engine.standbyCueIndex(), -1); + } + + void listLoad_rearmsStandbyAtFirstCue() { + CueList cues; + cues.addCue(Cue(1.0, "A")); + cues.addCue(Cue(2.0, "B")); + cues.addCue(Cue(3.0, "C")); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.go(); + engine.go(); // current 1, standby 2 + + CueList smaller; + smaller.addCue(Cue(9.0, "Z")); + cues.fromJson(smaller.toJson()); // the Open Show path + + QCOMPARE(cues.count(), 1); + QCOMPARE(engine.currentCueIndex(), -1); + QCOMPARE(engine.standbyCueIndex(), 0); + QCOMPARE(engine.state(), PlaybackState::Stopped); + } + + void listLoad_emptyShow_leavesNothingArmed() { + CueList cues; + cues.addCue(Cue(1.0, "A")); + + PlaybackEngine engine; + engine.setCueList(&cues); // standby -> 0 + + cues.fromJson(QJsonArray()); + + QCOMPARE(engine.standbyCueIndex(), -1); + QCOMPARE(engine.standbyCue(), nullptr); + } + + void listClear_cancelsRunningFades() { + CueList cues; + Cue a(1.0, "A"); + a.setType(CueType::Snapshot); + a.setChannelLevel(5, 0.0); + Cue b(2.0, "B"); + b.setType(CueType::Snapshot); + b.setChannelLevel(5, 1.0); + b.setFadeTime(1.0); + cues.addCue(a); + cues.addCue(b); + + RecordingMixer* mixer = makeConnectedMixer(this); + PlaybackEngine engine; + engine.setCueList(&cues); + engine.setMixer(mixer); + engine.executeCue(0); // instant, seeds the applied level + engine.executeCue(1); // fades 0 -> 1 + QVERIFY(engine.fadeEngine()->isActive()); + + cues.clear(); + QVERIFY(!engine.fadeEngine()->isActive()); + } }; QTEST_MAIN(TestShowControl) diff --git a/tests/test_tmix_import.cpp b/tests/test_tmix_import.cpp index 2109e2b..c267633 100644 --- a/tests/test_tmix_import.cpp +++ b/tests/test_tmix_import.cpp @@ -110,6 +110,193 @@ class TestTmixImport : public QObject { QCOMPARE(summary.cues, 2); } + void testChannelNamesFromDefaultProfiles() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath("names.tmix"); + + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "tmixnames"); + db.setDatabaseName(path); + QVERIFY(db.open()); + QSqlQuery q(db); + // real files ship an empty actors table; the cast lives in profiles + QVERIFY(q.exec("CREATE TABLE actors (id INTEGER PRIMARY KEY, channel INTEGER, " + "name TEXT, `order` INTEGER, active INTEGER)")); + QVERIFY(q.exec("CREATE TABLE profiles (id INTEGER PRIMARY KEY, channel INTEGER, " + "name TEXT, label TEXT, `default` INTEGER, data TEXT)")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(1,3,'Alice','',1,'')")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(2,3,'Umbrella','',0,'')")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(3,4,'Bob','',1,'')")); + QVERIFY(q.exec("CREATE TABLE cues (number INTEGER, point INTEGER, name TEXT, " + "channelProfiles TEXT)")); + QVERIFY(q.exec("INSERT INTO cues VALUES(1,0,'One','3=2,4=3')")); + db.close(); + } + QSqlDatabase::removeDatabase("tmixnames"); + + Show show; + TmixImporter importer; + QString err; + TmixImportSummary summary; + QVERIFY2(importer.import(path, &show, &err, &summary), qPrintable(err)); + + const ActorProfileLibrary* lib = show.actorProfileLibrary(); + QCOMPARE(lib->actors().size(), 2); + QVERIFY(lib->actorForChannel(3)); + QCOMPARE(lib->actorForChannel(3)->name(), QString("Alice")); + QVERIFY(lib->actorForChannel(4)); + QCOMPARE(lib->actorForChannel(4)->name(), QString("Bob")); + // only the non-default preset becomes a slot + QCOMPARE(lib->profileSlots(), QStringList({"Main", "Umbrella"})); + QCOMPARE(summary.actors, 2); + QCOMPARE(summary.channelNames, 2); + QCOMPARE(summary.profileSlots, QStringList({"Umbrella"})); + // cue references resolve: non-default id to its slot, default id to Main + QCOMPARE(show.cueList()->at(0).channelProfiles().value(3), QString("Umbrella")); + QCOMPARE(show.cueList()->at(0).channelProfiles().value(4), QString("Main")); + } + + void testChannelNamesWithoutLabelColumn() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath("oldnames.tmix"); + + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "tmixoldnames"); + db.setDatabaseName(path); + QVERIFY(db.open()); + QSqlQuery q(db); + // older files: profiles has no label column + QVERIFY(q.exec("CREATE TABLE profiles (id INTEGER PRIMARY KEY, channel INTEGER, " + "name TEXT, `default` INTEGER, data TEXT)")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(1,1,'Barry',1,'')")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(2,2,'Trent',1,'')")); + db.close(); + } + QSqlDatabase::removeDatabase("tmixoldnames"); + + Show show; + TmixImporter importer; + QString err; + TmixImportSummary summary; + QVERIFY2(importer.import(path, &show, &err, &summary), qPrintable(err)); + + const ActorProfileLibrary* lib = show.actorProfileLibrary(); + QCOMPARE(lib->actors().size(), 2); + QCOMPARE(lib->actorForChannel(1)->name(), QString("Barry")); + QCOMPARE(lib->actorForChannel(2)->name(), QString("Trent")); + QCOMPARE(lib->profileSlots(), QStringList({"Main"})); + QVERIFY(summary.profileSlots.isEmpty()); + QCOMPARE(summary.channelNames, 2); + } + + void testActorsTableNameWins() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath("actorswin.tmix"); + + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "tmixactorswin"); + db.setDatabaseName(path); + QVERIFY(db.open()); + QSqlQuery q(db); + QVERIFY(q.exec("CREATE TABLE actors (id INTEGER PRIMARY KEY, channel INTEGER, " + "name TEXT, `order` INTEGER, active INTEGER)")); + QVERIFY(q.exec("INSERT INTO actors (channel,name,`order`,active) VALUES(3,'Alice',0,1)")); + QVERIFY(q.exec("INSERT INTO actors (channel,name,`order`,active) VALUES(5,'',1,1)")); + QVERIFY(q.exec("CREATE TABLE profiles (id INTEGER PRIMARY KEY, channel INTEGER, " + "name TEXT, label TEXT, `default` INTEGER, data TEXT)")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(1,3,'Wrong','',1,'')")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(2,5,'Eve','',1,'')")); + QVERIFY(q.exec("CREATE TABLE cues (number INTEGER, point INTEGER, name TEXT, " + "channelProfiles TEXT)")); + QVERIFY(q.exec("INSERT INTO cues VALUES(1,0,'One','3=1')")); + db.close(); + } + QSqlDatabase::removeDatabase("tmixactorswin"); + + Show show; + TmixImporter importer; + QString err; + TmixImportSummary summary; + QVERIFY2(importer.import(path, &show, &err, &summary), qPrintable(err)); + + const ActorProfileLibrary* lib = show.actorProfileLibrary(); + QCOMPARE(lib->actors().size(), 2); // filled in, not duplicated + QCOMPARE(lib->actorForChannel(3)->name(), QString("Alice")); + QCOMPARE(lib->actorForChannel(5)->name(), QString("Eve")); + QCOMPARE(summary.actors, 2); + QCOMPARE(summary.channelNames, 1); + QCOMPARE(show.cueList()->at(0).channelProfiles().value(3), QString("Main")); + } + + void testFlatProfilesStillBecomeSlots() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath("flat.tmix"); + + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "tmixflat"); + db.setDatabaseName(path); + QVERIFY(db.open()); + QSqlQuery q(db); + // no channel column: every label is a show-wide slot + QVERIFY(q.exec("CREATE TABLE profiles (id INTEGER PRIMARY KEY, label TEXT)")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(1,'Loud')")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(2,'Soft')")); + QVERIFY(q.exec("CREATE TABLE cues (number INTEGER, point INTEGER, name TEXT, " + "channelProfiles TEXT)")); + QVERIFY(q.exec("INSERT INTO cues VALUES(1,0,'One','3=1')")); + db.close(); + } + QSqlDatabase::removeDatabase("tmixflat"); + + Show show; + TmixImporter importer; + QString err; + TmixImportSummary summary; + QVERIFY2(importer.import(path, &show, &err, &summary), qPrintable(err)); + + QCOMPARE(show.actorProfileLibrary()->profileSlots(), QStringList({"Main", "Loud", "Soft"})); + QVERIFY(show.actorProfileLibrary()->actors().isEmpty()); + QCOMPARE(summary.channelNames, 0); + QCOMPARE(show.cueList()->at(0).channelProfiles().value(3), QString("Loud")); + } + + void testPerChannelProfilesWithoutDefaultFlag() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath("nodefault.tmix"); + + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "tmixnodefault"); + db.setDatabaseName(path); + QVERIFY(db.open()); + QSqlQuery q(db); + // no default column: the first row per channel is the default + QVERIFY(q.exec("CREATE TABLE profiles (id INTEGER PRIMARY KEY, channel INTEGER, " + "name TEXT)")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(1,3,'Alice')")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(2,3,'Umbrella')")); + QVERIFY(q.exec("INSERT INTO profiles VALUES(3,4,'Bob')")); + db.close(); + } + QSqlDatabase::removeDatabase("tmixnodefault"); + + Show show; + TmixImporter importer; + QString err; + TmixImportSummary summary; + QVERIFY2(importer.import(path, &show, &err, &summary), qPrintable(err)); + + const ActorProfileLibrary* lib = show.actorProfileLibrary(); + QCOMPARE(lib->actors().size(), 2); + QCOMPARE(lib->actorForChannel(3)->name(), QString("Alice")); + QCOMPARE(lib->actorForChannel(4)->name(), QString("Bob")); + QCOMPARE(lib->profileSlots(), QStringList({"Main", "Umbrella"})); + } + void testImportMissingFileFails() { Show show; TmixImporter importer;