From e70d92f355410708e70a59dd0e2dccc12c2c0ab6 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 12:54:31 -0400 Subject: [PATCH 01/12] fix: crash on File > New Show with unsaved changes PlaybackEngine kept stale cue indices when the cue list was bulk-cleared (only cueRemoved was wired), so updateStatusBar dereferenced the null standby/current cue during Show::newShow. Reset the engine on listCleared/listLoaded, key the status bar off the bounds-checked cue pointers, and drop DCAMappingPanel's cue pointer on listCleared (it pointed into the cleared QVector). --- src/core/PlaybackEngine.cpp | 11 +++++ src/core/PlaybackEngine.h | 1 + src/ui/DCAMappingPanel.cpp | 6 +++ src/ui/MainWindow.cpp | 10 ++-- tests/test_show_control.cpp | 94 +++++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 78794df..dfc80b5 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,15 @@ void PlaybackEngine::onCueRemoved(int index) { } } +void PlaybackEngine::onCueListReset() { + // the whole list was cleared or replaced (new show / show load): abandon + // in-flight fades and their stale per-channel sources, then re-arm from the + // top (stop() leaves standby at 0 when the new list has cues, -1 when empty) + 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/ui/DCAMappingPanel.cpp b/src/ui/DCAMappingPanel.cpp index bb37751..847bd0b 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" @@ -87,6 +88,11 @@ 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 (new show / show load) destroys the cue this panel + // points at; drop back to show level before anything repaints + connect(m_app->show()->cueList(), &CueList::listCleared, this, + &DCAMappingPanel::clearCurrentCue); } refresh(); diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3ca968f..ab54936 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1360,9 +1360,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 +1370,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())); 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) From 8fd63f890388f2f045f2f04b99a353afdb2b133b Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 13:01:03 -0400 Subject: [PATCH 02/12] fix: import .tmix channel names as actor names Real .tmix files keep the cast in the profiles table (one default row per channel holding its show-setup name) and ship an empty actors table, so imports produced no actor names and the console scribble strips stayed blank. Default profile rows now fill actor names (the actors table still wins when populated), only non-default presets become voice slots, and default profile ids resolve to the Main slot in cue channel-profile references. Verified against two real show files; summary dialog and quick-start copy updated to match, and the upstream app is no longer referred to by name anywhere in the source. --- src/io/TmixImporter.cpp | 96 +++++++++++++++---- src/io/TmixImporter.h | 9 +- src/ui/MainWindow.cpp | 35 ++++--- tests/test_tmix_import.cpp | 187 +++++++++++++++++++++++++++++++++++++ 4 files changed, 294 insertions(+), 33 deletions(-) diff --git a/src/io/TmixImporter.cpp b/src/io/TmixImporter.cpp index 8af4929..7242b85 100644 --- a/src/io/TmixImporter.cpp +++ b/src/io/TmixImporter.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -66,6 +67,36 @@ void clearShow(Show* show) { show->ensembleLibrary()->removeEnsemble(e.id()); } +// give the actor on `channel` this name: an existing named actor wins (the +// actors table is authoritative), an unnamed one is filled in, otherwise a +// new active actor is created +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 +138,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 list when present; usually empty in + // real show files, which carry the cast in profiles instead) if (q.exec("SELECT * FROM actors")) { while (q.next()) { const QSqlRecord r = q.record(); @@ -138,6 +152,50 @@ bool TmixImporter::import(const QString& path, Show* show, QString* error, } } + // profiles: per-channel voice presets. Each channel's default row + // carries its Show Setup channel name (the cast name), so it fills + // the actor name for that channel; only additional presets become + // show-wide voice profile slots. Files without a channel column + // keep the old every-label-is-a-slot mapping. + 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; // channels whose default row was handled + 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..ab075e3 100644 --- a/src/io/TmixImporter.h +++ b/src/io/TmixImporter.h @@ -14,13 +14,18 @@ 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 the file's channel names + // (each channel's default profile row) 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/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index ab54936..1dccf91 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1075,25 +1075,36 @@ void MainWindow::importTmixShow() { updateTitle(); updateStatusBar(); - // explain how TheatreMix concepts landed in OpenMix + // explain how the imported show's 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 +1112,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(); } @@ -1541,8 +1552,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/tests/test_tmix_import.cpp b/tests/test_tmix_import.cpp index 2109e2b..f5aef8f 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 (old behavior) + 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; From 898c1d0b1ffe2493607045c645bd5546d054ad2b Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 13:02:12 -0400 Subject: [PATCH 03/12] fix: live-update DCA mapping panel on cue edits The panel only repainted on cue selection change plus one guarded path from the cue editor, so inline table edits, undo/redo and paste never reached it. Route every cue-content change through CueList::cueUpdated and re-set the panel's current cue when the edited cue is the one being viewed. Also re-freshens the panel's cue pointer after list mutations. --- src/ui/MainWindow.cpp | 21 ++++++++++++--------- tests/test_cue_table_model.cpp | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 1dccf91..f687ec3 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -898,15 +898,18 @@ 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]); - } + }); + + // any cue-content change (editor fields, inline table edits, undo/redo, + // paste/fill-down) repaints the DCA panel when it is showing that cue; + // skip when hidden: the rebuild is per-keystroke while typing in the editor + 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); 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")); From 32943c4b7e76f4191dd03ee5ff36403bef749958 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 13:03:45 -0400 Subject: [PATCH 04/12] feat: show per-cue DCA names in mapping-page dropdowns Channel/bus assignment combos read "DCA n" plus a count only; the cue's DCA label was visible in the overview section but not where the assignment is actually made. Share one dcaDisplayName helper between the overview titles and the combo item texts ("DCA 3: Hamlet (2)") and pin a stable combo width so item text growth cannot jitter the grid. --- src/ui/DCAMappingPanel.cpp | 47 +++++++++++++++++++++++--------------- src/ui/DCAMappingPanel.h | 1 + 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/ui/DCAMappingPanel.cpp b/src/ui/DCAMappingPanel.cpp index 847bd0b..16e8276 100644 --- a/src/ui/DCAMappingPanel.cpp +++ b/src/ui/DCAMappingPanel.cpp @@ -341,6 +341,9 @@ void DCAMappingPanel::createChannelSection() { m_channelLayout->addWidget(nameContainer, row, colOffset); NoScrollComboBox* combo = new NoScrollComboBox(m_channelGroup); + // item texts grow cue labels later; 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); @@ -407,6 +410,9 @@ void DCAMappingPanel::createBusSection() { m_busLayout->addWidget(nameContainer, row, colOffset); NoScrollComboBox* combo = new NoScrollComboBox(m_busGroup); + // item texts grow cue labels later; 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); @@ -583,21 +589,22 @@ void DCAMappingPanel::updateComboItemStates() { } } - // update combo box items w/ assignment counts + // update combo box items w/ the cue's DCA labels and assignment counts + QStringList itemTexts; + for (int d = 1; d <= m_dcaCount; ++d) { + const int total = channelCounts[d] + busCounts[d]; + const QString name = dcaDisplayName(d); + itemTexts << (total > 0 ? tr("%1 (%2)").arg(name).arg(total) : name); + } + 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); - } + for (int d = 1; d <= m_dcaCount; ++d) + combo->setItemText(d, itemTexts[d - 1]); } 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); - } + for (int d = 1; d <= m_dcaCount; ++d) + combo->setItemText(d, itemTexts[d - 1]); } } @@ -972,6 +979,16 @@ QString DCAMappingPanel::channelDisplayName(int channel) const { return tr("Ch %1").arg(channel); } +QString DCAMappingPanel::dcaDisplayName(int dca) const { + // the current cue's scribble label for this DCA, matching the overview + 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); +} + void DCAMappingPanel::updateDcaOverview() { if (!m_mapping) return; @@ -988,13 +1005,7 @@ 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)); QStringList parts; for (int ch : channelMap.value(d)) diff --git a/src/ui/DCAMappingPanel.h b/src/ui/DCAMappingPanel.h index 697394d..30f5590 100644 --- a/src/ui/DCAMappingPanel.h +++ b/src/ui/DCAMappingPanel.h @@ -79,6 +79,7 @@ class DCAMappingPanel : public QWidget { void updateDcaOverview(); QString busDisplayName(int bus) const; QString channelDisplayName(int channel) const; + QString dcaDisplayName(int dca) const; Application* m_app; DCAMapping* m_mapping; From 4e74e0cb3cb6e98eca7f2cea2d6db83bab156823 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 13:04:51 -0400 Subject: [PATCH 05/12] feat: add Import Show button to the welcome dialog Reuses the File menu's import flow so a first launch can pull in an existing .tmix file without hunting through menus. --- src/ui/MainWindow.cpp | 3 +++ src/ui/WelcomeDialog.cpp | 13 ++++++++++++- src/ui/WelcomeDialog.h | 3 ++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index f687ec3..4fae15e 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1240,6 +1240,9 @@ void MainWindow::showWelcomeDialog() { updateRecentProjectsMenu(); break; } + case WelcomeDialog::Choice::Import: + importTmixShow(); + break; case WelcomeDialog::Choice::None: break; } 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: From c9a0ccec01cd1ac5f7c2da6d9b35f446b1c74def Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 13:05:40 -0400 Subject: [PATCH 06/12] fix: stop MIDI dialog buttons floating below empty tables The hard 620px minimum height plus the tables' default ~192px size hint inflated both groups with empty viewport, stranding the button rows mid-dialog. Drop the height floor, size tables to their rows, and right-align the button rows to match the other list editors. --- src/ui/MidiConfigDialog.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/ui/MidiConfigDialog.cpp b/src/ui/MidiConfigDialog.cpp index 9ee54ca..64c5576 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 @@ -109,10 +109,13 @@ void MidiConfigDialog::setupUi() { m_mappingsTable->verticalHeader()->setVisible(false); m_mappingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); m_mappingsTable->setMinimumHeight(140); + // size to the actual rows so empty tables don't open with a dead band + 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,8 +127,6 @@ 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); @@ -151,10 +152,13 @@ void MidiConfigDialog::setupUi() { m_mutesTable->verticalHeader()->setVisible(false); m_mutesTable->setEditTriggers(QAbstractItemView::NoEditTriggers); m_mutesTable->setMinimumHeight(140); + // size to the actual rows so empty tables don't open with a dead band + 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 +169,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); From 9a6e6f01adbb3b2bee8c0d82ecf5d280cd4c664d Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 13:52:57 -0400 Subject: [PATCH 07/12] feat: per-actor choice of role name as the channel label Some channels aren't tied to one person (shared mic packs), so the role is the more useful label. Adds a "Label channel with role name" checkbox in Actor Setup: when set, the console scribble strips and channel displays use the primary role, with the actor name moving to the parenthesised spot. Falls back to the actor name when no role is set; persists in the show file (older builds ignore the key). --- src/core/Actor.cpp | 14 ++++++++++++++ src/core/Actor.h | 16 ++++++++++++++++ src/core/ScribbleController.cpp | 2 +- src/ui/ActorSetupPanel.cpp | 22 ++++++++++++++++++++++ src/ui/ActorSetupPanel.h | 2 ++ src/ui/ChannelStripPanel.cpp | 3 ++- src/ui/ChannelUtilizationDialog.cpp | 2 +- src/ui/CueEditor.cpp | 17 ++++++++--------- src/ui/CueTableModel.cpp | 22 ++++++++++------------ src/ui/DCAMappingPanel.cpp | 10 ++++++---- tests/test_actor_profiles.cpp | 29 +++++++++++++++++++++++++++++ tests/test_scribble.cpp | 21 +++++++++++++++++++++ 12 files changed, 132 insertions(+), 28 deletions(-) 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..8d82714 100644 --- a/src/core/Actor.h +++ b/src/core/Actor.h @@ -31,6 +31,21 @@ class Actor { // the stored role case-insensitively equal to text, or empty when none [[nodiscard]] QString matchedRole(const QString& text) const; + // label the channel with the primary role instead of the actor name + // (for channels that aren't tied to one person, e.g. shared mic packs) + [[nodiscard]] bool useRoleName() const noexcept { return m_useRoleName; } + void setUseRoleName(bool use) { m_useRoleName = use; } + + // primary label for this actor's channel (console scribble strips and + // channel displays): the role when preferred and present, else the name + [[nodiscard]] QString displayName() const { + return (m_useRoleName && !m_roles.isEmpty()) ? m_roles.first() : m_name; + } + // the complementary label shown in parentheses next to displayName(): + // normally the role (the matched one when given), the actor name when the + // role is the display name; empty when redundant or absent + [[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 +72,7 @@ class Actor { int m_channel = 0; int m_order = 0; bool m_active = true; + bool m_useRoleName = false; // channel labelled by role instead of name QMap m_profiles; // slot -> profile }; 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/ui/ActorSetupPanel.cpp b/src/ui/ActorSetupPanel.cpp index e067b55..16a2704 100644 --- a/src/ui/ActorSetupPanel.cpp +++ b/src/ui/ActorSetupPanel.cpp @@ -499,12 +499,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 +520,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); @@ -668,6 +674,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 +690,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); @@ -937,6 +945,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); // changed() relabels displays + console + 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..f151358 100644 --- a/src/ui/ActorSetupPanel.h +++ b/src/ui/ActorSetupPanel.h @@ -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 @@ -112,6 +113,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/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..88201eb 100644 --- a/src/ui/CueEditor.cpp +++ b/src/ui/CueEditor.cpp @@ -737,13 +737,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 +778,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/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 16e8276..d156101 100644 --- a/src/ui/DCAMappingPanel.cpp +++ b/src/ui/DCAMappingPanel.cpp @@ -972,10 +972,12 @@ 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); } 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_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); From 40dcf71b7049194df37f86c50c9cd3a4b31d843e Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 14:26:23 -0400 Subject: [PATCH 08/12] refactor: de-duplicate names across the DCA mapping page The overview repeated the DCA name inside its member list, and each assigned channel row said the DCA number and name three times (label badge, closed combo, popup item). Overview members now drop whichever part of the channel label matches the DCA name, the [n] badge is gone (the tooltip keeps it), and closed combos paint a compact "DCA 7 (1)" while the opened menu keeps the full named items. --- src/ui/DCAMappingPanel.cpp | 68 +++++++++++++++++++++++++++++++++----- src/ui/DCAMappingPanel.h | 1 + 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/ui/DCAMappingPanel.cpp b/src/ui/DCAMappingPanel.cpp index d156101..c6e0773 100644 --- a/src/ui/DCAMappingPanel.cpp +++ b/src/ui/DCAMappingPanel.cpp @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include #include #include @@ -41,7 +43,25 @@ class NoScrollComboBox : public QComboBox { public: using QComboBox::QComboBox; + // shorter text painted when the combo is closed (the popup keeps the full + // item text); lets the grid avoid repeating names the row already shows + 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 { @@ -559,7 +579,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") @@ -579,7 +599,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 { @@ -589,22 +609,30 @@ void DCAMappingPanel::updateComboItemStates() { } } - // update combo box items w/ the cue's DCA labels and assignment counts + // popup items carry the cue's DCA labels and assignment counts; the closed + // combo paints the compact form so the grid doesn't repeat names 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) + 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_busCombos) { - for (int d = 1; d <= m_dcaCount; ++d) + for (int d = 1; d <= m_dcaCount; ++d) { combo->setItemText(d, itemTexts[d - 1]); + combo->setItemData(d, compactTexts[d - 1], NoScrollComboBox::CompactTextRole); + } } } @@ -991,6 +1019,25 @@ QString DCAMappingPanel::dcaDisplayName(int dca) const { 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; @@ -1009,11 +1056,16 @@ void DCAMappingPanel::updateDcaOverview() { 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 30f5590..53d2f18 100644 --- a/src/ui/DCAMappingPanel.h +++ b/src/ui/DCAMappingPanel.h @@ -80,6 +80,7 @@ class DCAMappingPanel : public QWidget { 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; From a6b99b8389cea257d8771866e1ab58bc58c83a4e Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 16:34:58 -0400 Subject: [PATCH 09/12] fix: inline cue-table editors overflowing compact rows The app stylesheet's input padding and 18px minimum height made the in-cell editors (~30px) taller than Small/Medium rows (22/28px), so the focus border floated over neighbouring rows while typing a role into a DCA cell. Strip the box model on all four delegate editors so they fit the cell exactly. --- src/ui/CueItemDelegates.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/ui/CueItemDelegates.cpp b/src/ui/CueItemDelegates.cpp index 6d1c3df..6abf926 100644 --- a/src/ui/CueItemDelegates.cpp +++ b/src/ui/CueItemDelegates.cpp @@ -17,6 +17,19 @@ namespace OpenMix { +namespace { + +// the app stylesheet gives inputs padding and a minimum height that overflow +// a compact table row, leaving the editor's border misaligned with the cell; +// 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 +55,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 +207,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 +298,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 +370,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)); From 83d56cb5d4f0c7c79f8d956a30f7c84e0047c8f0 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 16:37:42 -0400 Subject: [PATCH 10/12] fix: fit inline item-view editors app-wide via stylesheet The Actor Setup tree's role editor (default delegate) had the same overflow as the cue-table editors: the global input padding and minimum height made it taller than the row, so its focus border hung over the row below. Scope a stylesheet rule to line edits inside item views so every inline editor fits its cell, current and future. --- resources/styles/main.qss | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/resources/styles/main.qss b/resources/styles/main.qss index 5aafb93..83961d8 100644 --- a/resources/styles/main.qss +++ b/resources/styles/main.qss @@ -238,6 +238,18 @@ 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, leaving the + editor's focus border misaligned with the cell being edited */ +QTreeView QLineEdit, +QTableView QLineEdit, +QListView QLineEdit { + border: none; + border-radius: 0; + padding: 0 4px; + min-height: 0; +} + QComboBox::drop-down { border: none; width: 24px; From a444d2844f286414e21cb4b7df4f2791266c8465 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 16:50:41 -0400 Subject: [PATCH 11/12] feat: Add Actors / Add Roles buttons and multi-select delete The paste-list add flow already covers adding one actor, so the separate single Add button is gone: the row is now Add Actors..., Add Roles..., Remove. Add Roles appends deduplicated roles (commas or one per line) to every selected actor. The actor tree allows extended selection, and Remove deletes all selected actors behind one aggregate confirmation. --- src/ui/ActorSetupPanel.cpp | 123 ++++++++++++++++++++++----------- src/ui/ActorSetupPanel.h | 7 +- src/ui/CastTextParse.h | 11 +++ tests/test_cast_text_parse.cpp | 21 ++++++ 4 files changed, 118 insertions(+), 44 deletions(-) diff --git a/src/ui/ActorSetupPanel.cpp b/src/ui/ActorSetupPanel.cpp index 16a2704..764b445 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); @@ -597,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(); @@ -705,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); @@ -716,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); @@ -784,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(); } diff --git a/src/ui/ActorSetupPanel.h b/src/ui/ActorSetupPanel.h index f151358..0d1c29b 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; also the single-add path + void addRoles(); // append roles to every selected actor void removeActor(); void moveActorUp(); void moveActorDown(); @@ -80,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; @@ -93,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; 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/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"); From fe43a702abf4e22669d0b4b8f1760450f91cfdb5 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Sat, 4 Jul 2026 17:22:44 -0400 Subject: [PATCH 12/12] chore: strip comments that restate code Branch-added comments cut to invisible-constraint one-liners, plus a repo-wide sweep of label-the-obvious comments (dialog section labels, call restaters, dangling markers). Comment-only change. --- resources/styles/main.qss | 3 +-- src/core/Actor.h | 10 ++-------- src/core/ActorProfile.h | 4 ++-- src/core/PlaybackEngine.cpp | 3 --- src/core/ShortcutManager.cpp | 1 - src/io/TmixImporter.cpp | 19 ++++++++----------- src/io/TmixImporter.h | 3 +-- .../allenheath/AllenHeathTcpProtocol.cpp | 2 -- src/protocol/behringer/WingProtocol.cpp | 4 ---- src/ui/ActorSetupPanel.cpp | 2 +- src/ui/ActorSetupPanel.h | 4 ++-- src/ui/CueEditor.cpp | 1 - src/ui/CueItemDelegates.cpp | 5 ++--- src/ui/CueListView.cpp | 5 ----- src/ui/DCAMappingPanel.cpp | 14 ++++---------- src/ui/EnsemblePanel.cpp | 3 --- src/ui/KeyboardShortcutsDialog.cpp | 10 ---------- src/ui/LogViewerDialog.cpp | 7 ------- src/ui/MacroPreviewWidget.cpp | 2 -- src/ui/MainWindow.cpp | 6 ++---- src/ui/MidiConfigDialog.cpp | 7 ------- src/ui/TimecodePanel.cpp | 2 -- tests/test_tmix_import.cpp | 2 +- 23 files changed, 26 insertions(+), 93 deletions(-) diff --git a/resources/styles/main.qss b/resources/styles/main.qss index 83961d8..fa5e5e7 100644 --- a/resources/styles/main.qss +++ b/resources/styles/main.qss @@ -239,8 +239,7 @@ QLineEdit[placeholderText] { } /* inline editors inside item views must fit their row: the input box model - above (padding + min-height) otherwise overflows compact rows, leaving the - editor's focus border misaligned with the cell being edited */ + above (padding + min-height) otherwise overflows compact rows */ QTreeView QLineEdit, QTableView QLineEdit, QListView QLineEdit { diff --git a/src/core/Actor.h b/src/core/Actor.h index 8d82714..639ae1d 100644 --- a/src/core/Actor.h +++ b/src/core/Actor.h @@ -31,19 +31,13 @@ class Actor { // the stored role case-insensitively equal to text, or empty when none [[nodiscard]] QString matchedRole(const QString& text) const; - // label the channel with the primary role instead of the actor name - // (for channels that aren't tied to one person, e.g. shared mic packs) [[nodiscard]] bool useRoleName() const noexcept { return m_useRoleName; } void setUseRoleName(bool use) { m_useRoleName = use; } - // primary label for this actor's channel (console scribble strips and - // channel displays): the role when preferred and present, else the name [[nodiscard]] QString displayName() const { return (m_useRoleName && !m_roles.isEmpty()) ? m_roles.first() : m_name; } - // the complementary label shown in parentheses next to displayName(): - // normally the role (the matched one when given), the actor name when the - // role is the display name; empty when redundant or absent + // 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; } @@ -72,7 +66,7 @@ class Actor { int m_channel = 0; int m_order = 0; bool m_active = true; - bool m_useRoleName = false; // channel labelled by role instead of name + 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 dfc80b5..a7757be 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -57,9 +57,6 @@ void PlaybackEngine::onCueRemoved(int index) { } void PlaybackEngine::onCueListReset() { - // the whole list was cleared or replaced (new show / show load): abandon - // in-flight fades and their stale per-channel sources, then re-arm from the - // top (stop() leaves standby at 0 when the new list has cues, -1 when empty) m_fadeEngine.cancelAll(); m_appliedChannelLevels.clear(); stop(); 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 7242b85..64dd80c 100644 --- a/src/io/TmixImporter.cpp +++ b/src/io/TmixImporter.cpp @@ -67,9 +67,8 @@ void clearShow(Show* show) { show->ensembleLibrary()->removeEnsemble(e.id()); } -// give the actor on `channel` this name: an existing named actor wins (the -// actors table is authoritative), an unnamed one is filled in, otherwise a -// new active actor is created +// 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()) @@ -138,8 +137,8 @@ bool TmixImporter::import(const QString& path, Show* show, QString* error, } } - // actors (authoritative cast list when present; usually empty in - // real show files, which carry the cast in profiles instead) + // 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(); @@ -152,18 +151,16 @@ bool TmixImporter::import(const QString& path, Show* show, QString* error, } } - // profiles: per-channel voice presets. Each channel's default row - // carries its Show Setup channel name (the cast name), so it fills - // the actor name for that channel; only additional presets become - // show-wide voice profile slots. Files without a channel column - // keep the old every-label-is-a-slot mapping. + // 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; // channels whose default row was handled + QSet defaultSeen; if (q.exec("SELECT * FROM profiles")) { while (q.next()) { const QSqlRecord r = q.record(); diff --git a/src/io/TmixImporter.h b/src/io/TmixImporter.h index ab075e3..be488c4 100644 --- a/src/io/TmixImporter.h +++ b/src/io/TmixImporter.h @@ -14,8 +14,7 @@ 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 the file's channel names - // (each channel's default profile row) + int channelNames = 0; // actor names taken from default profile rows QStringList profileSlots; }; 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 764b445..8c6069f 100644 --- a/src/ui/ActorSetupPanel.cpp +++ b/src/ui/ActorSetupPanel.cpp @@ -996,7 +996,7 @@ void ActorSetupPanel::onUseRoleNameToggled(bool on) { Actor copy = *a; copy.setUseRoleName(on); m_updatingUi = true; - m_library->updateActor(id, copy); // changed() relabels displays + console + m_library->updateActor(id, copy); m_updatingUi = false; } diff --git a/src/ui/ActorSetupPanel.h b/src/ui/ActorSetupPanel.h index 0d1c29b..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 addActors(); // paste one actor per line; also the single-add path - void addRoles(); // append roles to every selected actor + void addActors(); // paste one actor per line + void addRoles(); // append roles to all selected actors void removeActor(); void moveActorUp(); void moveActorDown(); diff --git a/src/ui/CueEditor.cpp b/src/ui/CueEditor.cpp index 88201eb..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); diff --git a/src/ui/CueItemDelegates.cpp b/src/ui/CueItemDelegates.cpp index 6abf926..bf7719e 100644 --- a/src/ui/CueItemDelegates.cpp +++ b/src/ui/CueItemDelegates.cpp @@ -19,9 +19,8 @@ namespace OpenMix { namespace { -// the app stylesheet gives inputs padding and a minimum height that overflow -// a compact table row, leaving the editor's border misaligned with the cell; -// strip the box model so inline cell editors fit the cell exactly +// 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; " 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/DCAMappingPanel.cpp b/src/ui/DCAMappingPanel.cpp index c6e0773..f7527f8 100644 --- a/src/ui/DCAMappingPanel.cpp +++ b/src/ui/DCAMappingPanel.cpp @@ -43,8 +43,7 @@ class NoScrollComboBox : public QComboBox { public: using QComboBox::QComboBox; - // shorter text painted when the combo is closed (the popup keeps the full - // item text); lets the grid avoid repeating names the row already shows + // shorter closed-state text; the popup keeps the full item text static constexpr int CompactTextRole = Qt::UserRole + 1; protected: @@ -99,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); @@ -109,8 +107,7 @@ DCAMappingPanel::DCAMappingPanel(Application* app, QWidget* parent) connect(m_app->show()->actorProfileLibrary(), &ActorProfileLibrary::changed, this, &DCAMappingPanel::onActorsChanged); - // a cleared cue list (new show / show load) destroys the cue this panel - // points at; drop back to show level before anything repaints + // a cleared cue list destroys the cue this panel points at connect(m_app->show()->cueList(), &CueList::listCleared, this, &DCAMappingPanel::clearCurrentCue); } @@ -361,7 +358,7 @@ void DCAMappingPanel::createChannelSection() { m_channelLayout->addWidget(nameContainer, row, colOffset); NoScrollComboBox* combo = new NoScrollComboBox(m_channelGroup); - // item texts grow cue labels later; keep the closed combo width stable + // item texts vary with cue labels; keep the closed combo width stable combo->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); combo->setMinimumContentsLength(14); combo->addItem(tr("None"), -1); @@ -430,7 +427,7 @@ void DCAMappingPanel::createBusSection() { m_busLayout->addWidget(nameContainer, row, colOffset); NoScrollComboBox* combo = new NoScrollComboBox(m_busGroup); - // item texts grow cue labels later; keep the closed combo width stable + // item texts vary with cue labels; keep the closed combo width stable combo->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); combo->setMinimumContentsLength(14); combo->addItem(tr("None"), -1); @@ -609,8 +606,6 @@ void DCAMappingPanel::updateComboItemStates() { } } - // popup items carry the cue's DCA labels and assignment counts; the closed - // combo paints the compact form so the grid doesn't repeat names QStringList itemTexts; QStringList compactTexts; for (int d = 1; d <= m_dcaCount; ++d) { @@ -1010,7 +1005,6 @@ QString DCAMappingPanel::channelDisplayName(int channel) const { } QString DCAMappingPanel::dcaDisplayName(int dca) const { - // the current cue's scribble label for this DCA, matching the overview if (m_currentCue) { const QString label = m_currentCue->dcaOverride(dca).label.value_or(QString()); if (!label.isEmpty()) 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 4fae15e..dade72a 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -900,9 +900,8 @@ void MainWindow::connectSignals() { m_cueListView->refreshCurrentCue(); }); - // any cue-content change (editor fields, inline table edits, undo/redo, - // paste/fill-down) repaints the DCA panel when it is showing that cue; - // skip when hidden: the rebuild is per-keystroke while typing in the editor + // 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; @@ -1078,7 +1077,6 @@ void MainWindow::importTmixShow() { updateTitle(); updateStatusBar(); - // explain how the imported show's concepts landed in OpenMix QString namesLine; if (summary.channelNames > 0) { namesLine = tr("
  • The file's channel names (from its show setup) became " diff --git a/src/ui/MidiConfigDialog.cpp b/src/ui/MidiConfigDialog.cpp index 64c5576..c9e7218 100644 --- a/src/ui/MidiConfigDialog.cpp +++ b/src/ui/MidiConfigDialog.cpp @@ -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,7 +107,6 @@ void MidiConfigDialog::setupUi() { m_mappingsTable->verticalHeader()->setVisible(false); m_mappingsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); m_mappingsTable->setMinimumHeight(140); - // size to the actual rows so empty tables don't open with a dead band m_mappingsTable->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); mappingsLayout->addWidget(m_mappingsTable); @@ -131,7 +128,6 @@ void MidiConfigDialog::setupUi() { 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); @@ -152,7 +148,6 @@ void MidiConfigDialog::setupUi() { m_mutesTable->verticalHeader()->setVisible(false); m_mutesTable->setEditTriggers(QAbstractItemView::NoEditTriggers); m_mutesTable->setMinimumHeight(140); - // size to the actual rows so empty tables don't open with a dead band m_mutesTable->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); mutesLayout->addWidget(m_mutesTable); @@ -242,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); @@ -260,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/tests/test_tmix_import.cpp b/tests/test_tmix_import.cpp index f5aef8f..c267633 100644 --- a/tests/test_tmix_import.cpp +++ b/tests/test_tmix_import.cpp @@ -241,7 +241,7 @@ class TestTmixImport : public QObject { db.setDatabaseName(path); QVERIFY(db.open()); QSqlQuery q(db); - // no channel column: every label is a show-wide slot (old behavior) + // 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')"));