diff --git a/CMakeLists.txt b/CMakeLists.txt index ad72948..335058f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,8 @@ set(SOURCES src/core/ShortcutManager.cpp src/core/OperationMode.cpp src/core/DCAMapping.cpp + src/core/AppLogger.cpp + src/core/ConnectionLogBridge.cpp src/ui/MainWindow.cpp src/ui/CueListView.cpp src/ui/CueEditor.cpp @@ -117,6 +119,9 @@ set(SOURCES src/midi/MidiInputManager.cpp src/ui/MidiConfigDialog.cpp src/ui/KeyboardShortcutsDialog.cpp + src/ui/LogViewerDialog.cpp + src/ui/LogListModel.cpp + src/ui/LogItemDelegate.cpp ) # header files @@ -134,6 +139,8 @@ set(HEADERS src/core/ShortcutManager.h src/core/OperationMode.h src/core/DCAMapping.h + src/core/AppLogger.h + src/core/ConnectionLogBridge.h src/ui/MainWindow.h src/ui/CueListView.h src/ui/CueEditor.h @@ -193,6 +200,9 @@ set(HEADERS src/midi/MidiInputManager.h src/ui/MidiConfigDialog.h src/ui/KeyboardShortcutsDialog.h + src/ui/LogViewerDialog.h + src/ui/LogListModel.h + src/ui/LogItemDelegate.h ) # Qt resources diff --git a/resources/styles/main.qss b/resources/styles/main.qss index 004abba..1b2e497 100644 --- a/resources/styles/main.qss +++ b/resources/styles/main.qss @@ -720,13 +720,13 @@ QLabel[role="warning"] { color: #f59e0b; } -QFrame[frameShape="4"] { /* HLine */ +QFrame[frameShape="4"] { background-color: #2e323b; max-height: 1px; border: none; } -QFrame[frameShape="5"] { /* VLine */ +QFrame[frameShape="5"] { background-color: #2e323b; max-width: 1px; border: none; @@ -761,13 +761,13 @@ QListView::item:hover { QTableView::item:selected, QTreeView::item:selected, QListView::item:selected { - background-color: rgba(59, 130, 246, 0.2); + background-color: rgba(59, 130, 246, 0.4); } QTableView::item:selected:hover, QTreeView::item:selected:hover, QListView::item:selected:hover { - background-color: rgba(59, 130, 246, 0.25); + background-color: rgba(59, 130, 246, 0.45); } QHeaderView { @@ -1168,4 +1168,25 @@ QTableView:focus { QPushButton:focus { border-color: #3b82f6; +} + +QListView#LogListView { + background-color: #131416; + border: 1px solid #2e323b; + border-radius: 6px; + font-family: "JetBrains Mono", "Cascadia Code", "SF Mono", "Consolas", monospace; + font-size: 11px; +} + +QListView#LogListView::item { + padding: 4px 0; + border-bottom: 1px solid #1e2024; +} + +QListView#LogListView::item:hover { + background-color: #22252a; +} + +QListView#LogListView::item:selected { + background-color: rgba(59, 130, 246, 0.4); } \ No newline at end of file diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 318bfd7..d1f7b26 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -1,4 +1,6 @@ #include "Application.h" +#include "core/AppLogger.h" +#include "core/ConnectionLogBridge.h" #include "core/Cue.h" #include "core/CueList.h" #include "core/CueValidator.h" @@ -20,6 +22,9 @@ #include "protocol/discovery/probes/BehringerWingProbeStrategy.h" #include "protocol/discovery/probes/BehringerX32ProbeStrategy.h" #include "protocol/discovery/probes/YamahaOscProbeStrategy.h" +#include +#include +#include namespace OpenMix { @@ -51,6 +56,15 @@ Application::Application(QObject* parent) : QObject(parent) { m_discoveryService->registerStrategy(std::make_shared()); m_discoveryService->registerStrategy(std::make_shared()); m_discoveryService->registerStrategy(std::make_shared()); + + // application logging + m_appLogger = new AppLogger(this); + m_connectionLogBridge = new ConnectionLogBridge(m_appLogger, this); + + // setup log file + QString logDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + QDir().mkpath(logDir); + m_appLogger->setLogFile(logDir + "/application.log"); } Application::~Application() { @@ -128,10 +142,22 @@ void Application::connectToMixer(const QString& type, const QString& host, int p m_playbackEngine->setMixer(m_mixer); + // setup connection logging + m_connectionLogBridge->setConnectionContext(type, host, port); + m_connectionLogBridge->attachToMixer(m_mixer); + m_appLogger->logConnectionAttempt(type, host, port); + connect(m_mixer, &MixerProtocol::connected, this, [this]() { emit mixerConnected(); }); connect(m_mixer, &MixerProtocol::disconnected, this, [this]() { emit mixerDisconnected(); }); m_mixer->connect(host, port); + + QSettings settings; + settings.beginGroup("LastMixer"); + settings.setValue("host", host); + settings.setValue("type", type); + settings.setValue("port", port); + settings.endGroup(); } void Application::connectToDiscoveredConsole(const DiscoveredConsole& console) { @@ -148,14 +174,29 @@ void Application::connectToDiscoveredConsole(const DiscoveredConsole& console) { m_playbackEngine->setMixer(m_mixer); + // setup connection logging + QString host = console.address.toString(); + QString protocol = console.modelName; + m_connectionLogBridge->setConnectionContext(protocol, host, console.port); + m_connectionLogBridge->attachToMixer(m_mixer); + m_appLogger->logConnectionAttempt(protocol, host, console.port); + connect(m_mixer, &MixerProtocol::connected, this, [this]() { emit mixerConnected(); }); connect(m_mixer, &MixerProtocol::disconnected, this, [this]() { emit mixerDisconnected(); }); - m_mixer->connect(console.address.toString(), console.port); + m_mixer->connect(host, console.port); + + QSettings settings; + settings.beginGroup("LastMixer"); + settings.setValue("host", host); + settings.setValue("type", console.toCapabilities().protocolId); + settings.setValue("port", console.port); + settings.endGroup(); } void Application::disconnectFromMixer() { if (m_mixer) { + m_connectionLogBridge->detachFromMixer(); m_mixer->disconnect(); m_playbackEngine->setMixer(nullptr); delete m_mixer; @@ -163,4 +204,29 @@ void Application::disconnectFromMixer() { } } +void Application::startupScan() { + QSettings settings; + settings.beginGroup("LastMixer"); + QString savedHost = settings.value("host").toString(); + settings.endGroup(); + + if (!savedHost.isEmpty()) { + auto conn = std::make_shared(); + *conn = connect(m_discoveryService, &ConsoleDiscoveryService::consoleDiscovered, + this, [this, savedHost, conn](const DiscoveredConsole& console) { + if (m_mixer == nullptr && console.address.toString() == savedHost) { + QObject::disconnect(*conn); + connectToDiscoveredConsole(console); + } + }); + + connect(m_discoveryService, &ConsoleDiscoveryService::scanFinished, + this, [conn]() { + QObject::disconnect(*conn); + }, Qt::SingleShotConnection); + } + + m_discoveryService->startScan(3000); +} + } // namespace OpenMix diff --git a/src/app/Application.h b/src/app/Application.h index 569f1ee..ec405ef 100644 --- a/src/app/Application.h +++ b/src/app/Application.h @@ -20,6 +20,8 @@ class OperationModeManager; class CrashRecovery; class MidiInputManager; class ConsoleDiscoveryService; +class AppLogger; +class ConnectionLogBridge; struct DiscoveredConsole; class Application : public QObject { @@ -58,6 +60,9 @@ class Application : public QObject { // console discovery ConsoleDiscoveryService* discoveryService() { return m_discoveryService; } + // application logging + AppLogger* appLogger() { return m_appLogger; } + // mixer connection void connectToMixer(const QString& type, const QString& host, int port); void connectToDiscoveredConsole(const DiscoveredConsole& console); @@ -70,6 +75,9 @@ class Application : public QObject { // initialization void initialize(); + // startup auto-connect + void startupScan(); + signals: void mixerConnected(); void mixerDisconnected(); @@ -102,6 +110,10 @@ class Application : public QObject { // console discovery ConsoleDiscoveryService* m_discoveryService; + + // application logging + AppLogger* m_appLogger; + ConnectionLogBridge* m_connectionLogBridge; }; } // namespace OpenMix diff --git a/src/core/AppLogger.cpp b/src/core/AppLogger.cpp new file mode 100644 index 0000000..4a9e248 --- /dev/null +++ b/src/core/AppLogger.cpp @@ -0,0 +1,378 @@ +#include "AppLogger.h" +#include +#include +#include +#include + +namespace OpenMix { + +QString LogEntry::levelString() const { + switch (level) { + case LogLevel::Debug: + return "Debug"; + case LogLevel::Info: + return "Info"; + case LogLevel::Warning: + return "Warning"; + case LogLevel::Error: + return "Error"; + case LogLevel::Critical: + return "Critical"; + } + return "Unknown"; +} + +QString LogEntry::sourceString() const { + switch (source) { + case LogSource::Connection: + return "Connection"; + case LogSource::Protocol: + return "Protocol"; + case LogSource::Playback: + return "Playback"; + case LogSource::UI: + return "UI"; + case LogSource::System: + return "System"; + case LogSource::MIDI: + return "MIDI"; + case LogSource::Discovery: + return "Discovery"; + } + return "Unknown"; +} + +QJsonObject LogEntry::toJson() const { + QJsonObject obj; + obj["timestamp"] = timestamp.toString(Qt::ISODateWithMs); + obj["level"] = levelString(); + obj["source"] = sourceString(); + obj["message"] = message; + if (!metadata.isEmpty()) { + obj["metadata"] = metadata; + } + return obj; +} + +LogEntry LogEntry::fromJson(const QJsonObject& json) { + LogEntry entry; + entry.timestamp = QDateTime::fromString(json["timestamp"].toString(), Qt::ISODateWithMs); + entry.level = levelFromString(json["level"].toString()); + entry.source = sourceFromString(json["source"].toString()); + entry.message = json["message"].toString(); + entry.metadata = json["metadata"].toObject(); + return entry; +} + +LogLevel LogEntry::levelFromString(const QString& str) { + if (str == "Debug") + return LogLevel::Debug; + if (str == "Info") + return LogLevel::Info; + if (str == "Warning") + return LogLevel::Warning; + if (str == "Error") + return LogLevel::Error; + if (str == "Critical") + return LogLevel::Critical; + return LogLevel::Info; +} + +LogSource LogEntry::sourceFromString(const QString& str) { + if (str == "Connection") + return LogSource::Connection; + if (str == "Protocol") + return LogSource::Protocol; + if (str == "Playback") + return LogSource::Playback; + if (str == "UI") + return LogSource::UI; + if (str == "System") + return LogSource::System; + if (str == "MIDI") + return LogSource::MIDI; + if (str == "Discovery") + return LogSource::Discovery; + return LogSource::System; +} + +AppLogger::AppLogger(QObject* parent) : QObject(parent) { + m_batchTimer = new QTimer(this); + m_batchTimer->setSingleShot(true); + connect(m_batchTimer, &QTimer::timeout, this, &AppLogger::flushBatch); +} + +AppLogger::~AppLogger() { + flushBatch(); + closeLogFile(); +} + +void AppLogger::setLogFile(const QString& path) { + QMutexLocker locker(&m_mutex); + + closeLogFile(); + m_logFilePath = path; + + if (!path.isEmpty()) { + m_logFile.setFileName(path); + if (!m_logFile.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { + qWarning("AppLogger: Failed to open log file: %s", qPrintable(path)); + } + } +} + +void AppLogger::closeLogFile() { + if (m_logFile.isOpen()) { + m_logFile.close(); + } +} + +void AppLogger::setBatchInterval(int ms) { m_batchInterval = qMax(0, ms); } + +void AppLogger::log(LogLevel level, LogSource source, const QString& message, + const QJsonObject& metadata) { + LogEntry entry; + entry.timestamp = QDateTime::currentDateTime(); + entry.level = level; + entry.source = source; + entry.message = message; + entry.metadata = metadata; + + addEntry(entry); +} + +void AppLogger::debug(LogSource source, const QString& message, const QJsonObject& metadata) { + log(LogLevel::Debug, source, message, metadata); +} + +void AppLogger::info(LogSource source, const QString& message, const QJsonObject& metadata) { + log(LogLevel::Info, source, message, metadata); +} + +void AppLogger::warning(LogSource source, const QString& message, const QJsonObject& metadata) { + log(LogLevel::Warning, source, message, metadata); +} + +void AppLogger::error(LogSource source, const QString& message, const QJsonObject& metadata) { + log(LogLevel::Error, source, message, metadata); +} + +void AppLogger::critical(LogSource source, const QString& message, const QJsonObject& metadata) { + log(LogLevel::Critical, source, message, metadata); +} + +void AppLogger::logConnectionAttempt(const QString& protocol, const QString& host, int port) { + QJsonObject meta; + meta["protocol"] = protocol; + meta["host"] = host; + meta["port"] = port; + info(LogSource::Connection, + QString("Connecting to %1:%2 (%3)").arg(host).arg(port).arg(protocol), meta); +} + +void AppLogger::logConnectionSuccess(const QString& protocol, const QString& host, int port) { + QJsonObject meta; + meta["protocol"] = protocol; + meta["host"] = host; + meta["port"] = port; + info(LogSource::Connection, + QString("Connected to %1:%2 (%3)").arg(host).arg(port).arg(protocol), meta); +} + +void AppLogger::logConnectionFailed(const QString& protocol, const QString& host, int port, + const QString& error) { + QJsonObject meta; + meta["protocol"] = protocol; + meta["host"] = host; + meta["port"] = port; + meta["error"] = error; + this->error( + LogSource::Connection, + QString("Connection failed to %1:%2 (%3): %4").arg(host).arg(port).arg(protocol).arg(error), + meta); +} + +void AppLogger::logConnectionLost(const QString& protocol, const QString& host, int port) { + QJsonObject meta; + meta["protocol"] = protocol; + meta["host"] = host; + meta["port"] = port; + warning(LogSource::Connection, + QString("Connection lost to %1:%2 (%3)").arg(host).arg(port).arg(protocol), meta); +} + +void AppLogger::logReconnectAttempt(const QString& protocol, const QString& host, int port, + int attempt, int maxAttempts) { + QJsonObject meta; + meta["protocol"] = protocol; + meta["host"] = host; + meta["port"] = port; + meta["attempt"] = attempt; + meta["maxAttempts"] = maxAttempts; + info(LogSource::Connection, + QString("Reconnecting to %1:%2 (%3) - attempt %4/%5") + .arg(host) + .arg(port) + .arg(protocol) + .arg(attempt) + .arg(maxAttempts), + meta); +} + +void AppLogger::logDisconnected(const QString& protocol, const QString& host, int port) { + QJsonObject meta; + meta["protocol"] = protocol; + meta["host"] = host; + meta["port"] = port; + info(LogSource::Connection, + QString("Disconnected from %1:%2 (%3)").arg(host).arg(port).arg(protocol), meta); +} + +QVector AppLogger::allEntries() const { + QMutexLocker locker(&m_mutex); + return m_entries; +} + +QVector AppLogger::recentEntries(int count) const { + QMutexLocker locker(&m_mutex); + + if (count <= 0 || count >= m_entries.size()) { + return m_entries; + } + + return m_entries.mid(m_entries.size() - count); +} + +QVector AppLogger::entriesSince(const QDateTime& since) const { + QMutexLocker locker(&m_mutex); + + QVector result; + for (const LogEntry& entry : m_entries) { + if (entry.timestamp >= since) { + result.append(entry); + } + } + return result; +} + +QVector AppLogger::entriesFiltered(LogLevel minLevel, LogSource source, + bool filterBySource) const { + QMutexLocker locker(&m_mutex); + + QVector result; + for (const LogEntry& entry : m_entries) { + if (static_cast(entry.level) < static_cast(minLevel)) { + continue; + } + if (filterBySource && entry.source != source) { + continue; + } + result.append(entry); + } + return result; +} + +bool AppLogger::exportToFile(const QString& path) const { + QMutexLocker locker(&m_mutex); + + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + return false; + } + + QJsonArray array; + for (const LogEntry& entry : m_entries) { + array.append(entry.toJson()); + } + + QJsonDocument doc(array); + file.write(doc.toJson(QJsonDocument::Indented)); + return true; +} + +bool AppLogger::exportToCSV(const QString& path) const { + QMutexLocker locker(&m_mutex); + + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + return false; + } + + QTextStream stream(&file); + stream << "Timestamp,Level,Source,Message\n"; + + for (const LogEntry& entry : m_entries) { + QString message = entry.message; + message.replace("\"", "\"\""); + if (message.contains(',') || message.contains('\n') || message.contains('"')) { + message = "\"" + message + "\""; + } + + stream << entry.timestamp.toString(Qt::ISODateWithMs) << "," << entry.levelString() << "," + << entry.sourceString() << "," << message << "\n"; + } + + return true; +} + +void AppLogger::clear() { + { + QMutexLocker locker(&m_mutex); + m_entries.clear(); + m_pendingBatch.clear(); + } + emit logCleared(); +} + +void AppLogger::flushBatch() { + QVector batch; + { + QMutexLocker locker(&m_mutex); + if (m_pendingBatch.isEmpty()) { + return; + } + batch = m_pendingBatch; + m_pendingBatch.clear(); + } + + emit entriesBatchAdded(batch); +} + +void AppLogger::addEntry(const LogEntry& entry) { + { + QMutexLocker locker(&m_mutex); + m_entries.append(entry); + pruneOldEntries(); + writeToFile(entry); + m_pendingBatch.append(entry); + } + + emit entryAdded(entry); + + // schedule batch emission if not already pending + if (!m_batchTimer->isActive() && m_batchInterval > 0) { + m_batchTimer->start(m_batchInterval); + } +} + +void AppLogger::writeToFile(const LogEntry& entry) { + if (!m_logFile.isOpen()) + return; + + // write as JSON Lines format + QJsonDocument doc(entry.toJson()); + QTextStream stream(&m_logFile); + stream << doc.toJson(QJsonDocument::Compact) << "\n"; + stream.flush(); +} + +void AppLogger::pruneOldEntries() { + if (m_maxMemoryEntries <= 0) + return; + + while (m_entries.size() > m_maxMemoryEntries) { + m_entries.removeFirst(); + } +} + +} // namespace OpenMix diff --git a/src/core/AppLogger.h b/src/core/AppLogger.h new file mode 100644 index 0000000..522479f --- /dev/null +++ b/src/core/AppLogger.h @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +enum class LogLevel { Debug, Info, Warning, Error, Critical }; + +enum class LogSource { Connection, Protocol, Playback, UI, System, MIDI, Discovery }; + +struct LogEntry { + QDateTime timestamp; + LogLevel level; + LogSource source; + QString message; + QJsonObject metadata; + + QString levelString() const; + QString sourceString() const; + QJsonObject toJson() const; + static LogEntry fromJson(const QJsonObject& json); + static LogLevel levelFromString(const QString& str); + static LogSource sourceFromString(const QString& str); +}; + +class AppLogger : public QObject { + Q_OBJECT + + public: + explicit AppLogger(QObject* parent = nullptr); + ~AppLogger() override; + + // file logging + void setLogFile(const QString& path); + QString logFilePath() const { return m_logFilePath; } + bool isFileLoggingEnabled() const { return m_logFile.isOpen(); } + void closeLogFile(); + + // memory management + void setMaxMemoryEntries(int max) { m_maxMemoryEntries = max; } + int maxMemoryEntries() const { return m_maxMemoryEntries; } + + // batch emission interval for UI updates (ms) + void setBatchInterval(int ms); + int batchInterval() const { return m_batchInterval; } + + // core logging method + void log(LogLevel level, LogSource source, const QString& message, + const QJsonObject& metadata = {}); + + // convenience methods by level + void debug(LogSource source, const QString& message, const QJsonObject& metadata = {}); + void info(LogSource source, const QString& message, const QJsonObject& metadata = {}); + void warning(LogSource source, const QString& message, const QJsonObject& metadata = {}); + void error(LogSource source, const QString& message, const QJsonObject& metadata = {}); + void critical(LogSource source, const QString& message, const QJsonObject& metadata = {}); + + // connection-specific convenience methods + void logConnectionAttempt(const QString& protocol, const QString& host, int port); + void logConnectionSuccess(const QString& protocol, const QString& host, int port); + void logConnectionFailed(const QString& protocol, const QString& host, int port, + const QString& error); + void logConnectionLost(const QString& protocol, const QString& host, int port); + void logReconnectAttempt(const QString& protocol, const QString& host, int port, int attempt, + int maxAttempts); + void logDisconnected(const QString& protocol, const QString& host, int port); + + // retrieve entries + QVector allEntries() const; + QVector recentEntries(int count = 100) const; + QVector entriesSince(const QDateTime& since) const; + QVector entriesFiltered(LogLevel minLevel = LogLevel::Debug, + LogSource source = LogSource::System, + bool filterBySource = false) const; + + // export + bool exportToFile(const QString& path) const; + bool exportToCSV(const QString& path) const; + + // clear logs + void clear(); + + signals: + void entryAdded(const LogEntry& entry); + void entriesBatchAdded(const QVector& entries); + void logCleared(); + + private slots: + void flushBatch(); + + private: + void addEntry(const LogEntry& entry); + void writeToFile(const LogEntry& entry); + void pruneOldEntries(); + + mutable QMutex m_mutex; + QVector m_entries; + int m_maxMemoryEntries = 5000; + + QString m_logFilePath; + QFile m_logFile; + + // batched emission for UI performance + QTimer* m_batchTimer; + QVector m_pendingBatch; + int m_batchInterval = 100; // ms +}; + +} // namespace OpenMix diff --git a/src/core/ConnectionLogBridge.cpp b/src/core/ConnectionLogBridge.cpp new file mode 100644 index 0000000..a642dbf --- /dev/null +++ b/src/core/ConnectionLogBridge.cpp @@ -0,0 +1,144 @@ +#include "ConnectionLogBridge.h" +#include "AppLogger.h" +#include "protocol/MixerProtocol.h" +#include +#include + +namespace OpenMix { + +ConnectionLogBridge::ConnectionLogBridge(AppLogger* logger, QObject* parent) + : QObject(parent), m_logger(logger) {} + +ConnectionLogBridge::~ConnectionLogBridge() { detachFromMixer(); } + +void ConnectionLogBridge::attachToMixer(MixerProtocol* mixer) { + if (m_mixer == mixer) { + return; + } + + detachFromMixer(); + + if (!mixer) { + return; + } + + m_mixer = mixer; + + connect(m_mixer, &MixerProtocol::connected, this, &ConnectionLogBridge::onConnected); + connect(m_mixer, &MixerProtocol::disconnected, this, &ConnectionLogBridge::onDisconnected); + connect(m_mixer, &MixerProtocol::connectionError, this, + &ConnectionLogBridge::onConnectionError); + connect(m_mixer, &MixerProtocol::connectionStateChanged, this, + &ConnectionLogBridge::onConnectionStateChanged); + connect(m_mixer, &MixerProtocol::connectionLost, this, &ConnectionLogBridge::onConnectionLost); + connect(m_mixer, &MixerProtocol::latencyChanged, this, &ConnectionLogBridge::onLatencyChanged); + connect(m_mixer, &MixerProtocol::requestTimeout, this, &ConnectionLogBridge::onRequestTimeout); +} + +void ConnectionLogBridge::detachFromMixer() { + if (!m_mixer) { + return; + } + + disconnect(m_mixer, nullptr, this, nullptr); + m_mixer = nullptr; +} + +void ConnectionLogBridge::setConnectionContext(const QString& protocol, const QString& host, + int port) { + m_protocol = protocol; + m_host = host; + m_port = port; +} + +void ConnectionLogBridge::onConnected() { + if (m_logger) { + m_logger->logConnectionSuccess(m_protocol, m_host, m_port); + } +} + +void ConnectionLogBridge::onDisconnected() { + if (m_logger) { + m_logger->logDisconnected(m_protocol, m_host, m_port); + } +} + +void ConnectionLogBridge::onConnectionError(const QString& error) { + if (m_logger) { + m_logger->logConnectionFailed(m_protocol, m_host, m_port, error); + } +} + +void ConnectionLogBridge::onConnectionStateChanged(ConnectionState state) { + if (!m_logger) { + return; + } + + QString stateStr; + switch (state) { + case ConnectionState::Disconnected: + stateStr = "Disconnected"; + break; + case ConnectionState::Connecting: + stateStr = "Connecting"; + break; + case ConnectionState::Connected: + stateStr = "Connected"; + break; + case ConnectionState::Reconnecting: + stateStr = "Reconnecting"; + break; + } + + QJsonObject meta; + meta["protocol"] = m_protocol; + meta["host"] = m_host; + meta["port"] = m_port; + meta["state"] = stateStr; + + m_logger->debug(LogSource::Connection, QString("Connection state changed: %1").arg(stateStr), + meta); +} + +void ConnectionLogBridge::onConnectionLost() { + if (m_logger) { + m_logger->logConnectionLost(m_protocol, m_host, m_port); + } +} + +void ConnectionLogBridge::onLatencyChanged(int ms) { + if (!m_logger) { + return; + } + + // throttle latency logging to avoid flooding + qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (now - m_lastLatencyLogTime < LATENCY_LOG_THROTTLE_MS) { + return; + } + m_lastLatencyLogTime = now; + + QJsonObject meta; + meta["protocol"] = m_protocol; + meta["host"] = m_host; + meta["port"] = m_port; + meta["latencyMs"] = ms; + + m_logger->debug(LogSource::Connection, QString("Latency: %1ms").arg(ms), meta); +} + +void ConnectionLogBridge::onRequestTimeout(const QString& path) { + if (!m_logger) { + return; + } + + QJsonObject meta; + meta["protocol"] = m_protocol; + meta["host"] = m_host; + meta["port"] = m_port; + meta["path"] = path; + + m_logger->warning(LogSource::Connection, QString("Request timeout: %1").arg(path), meta); +} + +} // namespace OpenMix diff --git a/src/core/ConnectionLogBridge.h b/src/core/ConnectionLogBridge.h new file mode 100644 index 0000000..c6383aa --- /dev/null +++ b/src/core/ConnectionLogBridge.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +namespace OpenMix { + +class AppLogger; +class MixerProtocol; +enum class ConnectionState; + +class ConnectionLogBridge : public QObject { + Q_OBJECT + + public: + explicit ConnectionLogBridge(AppLogger* logger, QObject* parent = nullptr); + ~ConnectionLogBridge() override; + + void attachToMixer(MixerProtocol* mixer); + void detachFromMixer(); + + void setConnectionContext(const QString& protocol, const QString& host, int port); + + MixerProtocol* attachedMixer() const { return m_mixer; } + + private slots: + void onConnected(); + void onDisconnected(); + void onConnectionError(const QString& error); + void onConnectionStateChanged(ConnectionState state); + void onConnectionLost(); + void onLatencyChanged(int ms); + void onRequestTimeout(const QString& path); + + private: + AppLogger* m_logger; + MixerProtocol* m_mixer = nullptr; + + QString m_protocol; + QString m_host; + int m_port = 0; + + // throttle latency logging + qint64 m_lastLatencyLogTime = 0; + static constexpr int LATENCY_LOG_THROTTLE_MS = 5000; +}; + +} // namespace OpenMix diff --git a/src/core/Cue.cpp b/src/core/Cue.cpp index 2732264..99faab0 100644 --- a/src/core/Cue.cpp +++ b/src/core/Cue.cpp @@ -1,4 +1,5 @@ #include "Cue.h" +#include "DCAMapping.h" #include namespace OpenMix { @@ -124,6 +125,8 @@ Cue::Cue(double number, const QString& name) m_autoFollowCondition(AutoFollowCondition::Always), m_macroExecutionMode(MacroExecutionMode::Sequential), m_gotoAutoExecute(false) {} +void Cue::regenerateId() { m_id = QUuid::createUuid().toString(QUuid::WithoutBraces); } + void Cue::setParameter(const QString& path, const QVariant& value) { m_parameters[path] = QJsonValue::fromVariant(value); } @@ -147,6 +150,36 @@ DCAOverride Cue::dcaOverride(int dca) const { return m_dcaOverrides.value(dca); void Cue::clearDCAOverride(int dca) { m_dcaOverrides.remove(dca); } +bool Cue::hasCustomDCAMapping() const { + return m_dcaChannelMapping.has_value() || m_dcaBusMapping.has_value(); +} + +void Cue::setDCAChannelMapping(const QMap>& mapping) { + m_dcaChannelMapping = mapping; +} + +void Cue::setDCABusMapping(const QMap>& mapping) { m_dcaBusMapping = mapping; } + +QMap> Cue::dcaChannelMapping() const { + return m_dcaChannelMapping.value_or(QMap>()); +} + +QMap> Cue::dcaBusMapping() const { + return m_dcaBusMapping.value_or(QMap>()); +} + +void Cue::clearCustomDCAMapping() { + m_dcaChannelMapping.reset(); + m_dcaBusMapping.reset(); +} + +void Cue::copyDCAMappingFrom(const DCAMapping* showMapping) { + if (showMapping) { + m_dcaChannelMapping = showMapping->channelAssignments(); + m_dcaBusMapping = showMapping->busAssignments(); + } +} + QJsonObject Cue::toJson() const { QJsonObject json; json["id"] = m_id; @@ -177,6 +210,35 @@ QJsonObject Cue::toJson() const { json["dcaOverrides"] = overridesObj; } + // per-cue DCA mapping + if (hasCustomDCAMapping()) { + QJsonObject dcaMappingObj; + if (m_dcaChannelMapping.has_value()) { + QJsonObject channelsObj; + for (auto it = m_dcaChannelMapping->constBegin(); it != m_dcaChannelMapping->constEnd(); + ++it) { + QJsonArray arr; + for (int ch : it.value()) { + arr.append(ch); + } + channelsObj[QString::number(it.key())] = arr; + } + dcaMappingObj["channels"] = channelsObj; + } + if (m_dcaBusMapping.has_value()) { + QJsonObject busesObj; + for (auto it = m_dcaBusMapping->constBegin(); it != m_dcaBusMapping->constEnd(); ++it) { + QJsonArray arr; + for (int bus : it.value()) { + arr.append(bus); + } + busesObj[QString::number(it.key())] = arr; + } + dcaMappingObj["buses"] = busesObj; + } + json["dcaMapping"] = dcaMappingObj; + } + if (!m_childCueIds.isEmpty()) { QJsonArray childIds; for (const QString& id : m_childCueIds) { @@ -239,6 +301,37 @@ Cue Cue::fromJson(const QJsonObject& json) { } } + // per-cue DCA mapping + if (json.contains("dcaMapping")) { + QJsonObject dcaMappingObj = json["dcaMapping"].toObject(); + if (dcaMappingObj.contains("channels")) { + QMap> channelMapping; + QJsonObject channelsObj = dcaMappingObj["channels"].toObject(); + for (auto it = channelsObj.constBegin(); it != channelsObj.constEnd(); ++it) { + int dca = it.key().toInt(); + QList channels; + for (const QJsonValue& val : it.value().toArray()) { + channels.append(val.toInt()); + } + channelMapping[dca] = channels; + } + cue.m_dcaChannelMapping = channelMapping; + } + if (dcaMappingObj.contains("buses")) { + QMap> busMapping; + QJsonObject busesObj = dcaMappingObj["buses"].toObject(); + for (auto it = busesObj.constBegin(); it != busesObj.constEnd(); ++it) { + int dca = it.key().toInt(); + QList buses; + for (const QJsonValue& val : it.value().toArray()) { + buses.append(val.toInt()); + } + busMapping[dca] = buses; + } + cue.m_dcaBusMapping = busMapping; + } + } + if (json.contains("childCueIds")) { QJsonArray childIds = json["childCueIds"].toArray(); for (const QJsonValue& val : childIds) { diff --git a/src/core/Cue.h b/src/core/Cue.h index 67ecf6b..f5fc73c 100644 --- a/src/core/Cue.h +++ b/src/core/Cue.h @@ -53,6 +53,8 @@ class Cue { explicit Cue(double number, const QString& name = QString()); QString id() const { return m_id; } + void regenerateId(); + double number() const { return m_number; } void setNumber(double number) { m_number = number; } @@ -95,6 +97,15 @@ class Cue { void clearDCAOverride(int dca); void clearAllDCAOverrides() { m_dcaOverrides.clear(); } + // per-cue DCA mapping (overrides show-level mapping during playback) + bool hasCustomDCAMapping() const; + void setDCAChannelMapping(const QMap>& mapping); + void setDCABusMapping(const QMap>& mapping); + QMap> dcaChannelMapping() const; + QMap> dcaBusMapping() const; + void clearCustomDCAMapping(); + void copyDCAMappingFrom(const class DCAMapping* showMapping); + bool isMacro() const { return m_type == CueType::Macro; } QStringList childCueIds() const { return m_childCueIds; } void setChildCueIds(const QStringList& ids) { m_childCueIds = ids; } @@ -152,6 +163,10 @@ class Cue { QSet m_targetedDCAs; // empty = all DCAs QMap m_dcaOverrides; // per-DCA mute/label overrides + // per-cue DCA mapping (overrides show-level mapping during playback) + std::optional>> m_dcaChannelMapping; // DCA# -> [channel#, ...] + std::optional>> m_dcaBusMapping; // DCA# -> [bus#, ...] + QStringList m_childCueIds; MacroExecutionMode m_macroExecutionMode; diff --git a/src/core/DCAMapping.cpp b/src/core/DCAMapping.cpp index 9be0bbd..4d06c75 100644 --- a/src/core/DCAMapping.cpp +++ b/src/core/DCAMapping.cpp @@ -95,6 +95,17 @@ int DCAMapping::dcaForBus(int bus) const { bool DCAMapping::isBusAssigned(int bus) const { return dcaForBus(bus) >= 0; } +void DCAMapping::setBusName(int bus, const QString& name) { + if (name.isEmpty()) { + m_busNames.remove(bus); + } else { + m_busNames[bus] = name; + } + emit busNameChanged(bus, name); +} + +QString DCAMapping::busName(int bus) const { return m_busNames.value(bus, QString()); } + void DCAMapping::clear() { m_channelAssignments.clear(); m_busAssignments.clear(); @@ -147,6 +158,14 @@ QJsonObject DCAMapping::toJson() const { } json["buses"] = busesObj; + if (!m_busNames.isEmpty()) { + QJsonObject busNamesObj; + for (auto it = m_busNames.constBegin(); it != m_busNames.constEnd(); ++it) { + busNamesObj[QString::number(it.key())] = it.value(); + } + json["busNames"] = busNamesObj; + } + return json; } @@ -159,6 +178,7 @@ DCAMapping* DCAMapping::fromJson(const QJsonObject& json, QObject* parent) { void DCAMapping::loadFromJson(const QJsonObject& json) { m_channelAssignments.clear(); m_busAssignments.clear(); + m_busNames.clear(); QJsonObject channelsObj = json["channels"].toObject(); for (auto it = channelsObj.constBegin(); it != channelsObj.constEnd(); ++it) { @@ -185,6 +205,15 @@ void DCAMapping::loadFromJson(const QJsonObject& json) { m_busAssignments[dca] = busList; } } + + QJsonObject busNamesObj = json["busNames"].toObject(); + for (auto it = busNamesObj.constBegin(); it != busNamesObj.constEnd(); ++it) { + int bus = it.key().toInt(); + QString name = it.value().toString(); + if (bus > 0 && !name.isEmpty()) { + m_busNames[bus] = name; + } + } } } // namespace OpenMix diff --git a/src/core/DCAMapping.h b/src/core/DCAMapping.h index 86180a2..94f506a 100644 --- a/src/core/DCAMapping.h +++ b/src/core/DCAMapping.h @@ -30,6 +30,11 @@ class DCAMapping : public QObject { int dcaForBus(int bus) const; // -1 = unassigned bool isBusAssigned(int bus) const; + // bus names + void setBusName(int bus, const QString& name); + QString busName(int bus) const; + QMap busNames() const { return m_busNames; } + // bulk operations void clear(); void setChannelAssignments(const QMap>& assignments); @@ -47,6 +52,7 @@ class DCAMapping : public QObject { signals: void channelAssignmentChanged(int channel, int dca); void busAssignmentChanged(int bus, int dca); + void busNameChanged(int bus, const QString& name); void mappingCleared(); private: @@ -54,6 +60,8 @@ class DCAMapping : public QObject { QMap> m_channelAssignments; // DCA# -> [bus#, ...] QMap> m_busAssignments; + // bus# -> user-defined name + QMap m_busNames; }; } // namespace OpenMix diff --git a/src/core/PlaybackEngine.cpp b/src/core/PlaybackEngine.cpp index 36db505..38a1f99 100644 --- a/src/core/PlaybackEngine.cpp +++ b/src/core/PlaybackEngine.cpp @@ -195,8 +195,8 @@ void PlaybackEngine::executeCueInternal(const Cue& cue) { switch (cue.type()) { case CueType::Snapshot: { - // filter params based on DCA targeting - QJsonObject filteredParams = filterParametersForDCAs(cue.parameters(), targetDCAs); + // filter params based on DCA targeting (uses cue-specific or show-level mapping) + QJsonObject filteredParams = filterParametersForDCAs(cue, targetDCAs); // instant recall of filtered params Cue filteredCue = cue; @@ -260,12 +260,37 @@ void PlaybackEngine::applyDCAOverrides(const Cue& cue, const QSet& targetDC } } -QJsonObject PlaybackEngine::filterParametersForDCAs(const QJsonObject& params, +QList PlaybackEngine::getChannelsForDCA(const Cue& cue, int dca) const { + // check cue-specific mapping first + if (cue.hasCustomDCAMapping()) { + return cue.dcaChannelMapping().value(dca); + } + // fall back to show-level mapping + if (m_dcaMapping) { + return m_dcaMapping->channelsForDCA(dca); + } + return {}; +} + +QList PlaybackEngine::getBusesForDCA(const Cue& cue, int dca) const { + // check cue-specific mapping first + if (cue.hasCustomDCAMapping()) { + return cue.dcaBusMapping().value(dca); + } + // fall back to show-level mapping + if (m_dcaMapping) { + return m_dcaMapping->busesForDCA(dca); + } + return {}; +} + +QJsonObject PlaybackEngine::filterParametersForDCAs(const Cue& cue, const QSet& targetDCAs) const { - if (!m_dcaMapping || targetDCAs.isEmpty()) { + bool hasMapping = cue.hasCustomDCAMapping() || m_dcaMapping; + if (!hasMapping || targetDCAs.isEmpty()) { QJsonObject filtered; static QRegularExpression dcaFaderRegex("^/dca/\\d+/fader$"); - for (auto it = params.begin(); it != params.end(); ++it) { + for (auto it = cue.parameters().begin(); it != cue.parameters().end(); ++it) { if (!dcaFaderRegex.match(it.key()).hasMatch()) { filtered[it.key()] = it.value(); } @@ -277,11 +302,11 @@ QJsonObject PlaybackEngine::filterParametersForDCAs(const QJsonObject& params, QSet targetChannels; QSet targetBuses; for (int dca : targetDCAs) { - QList channels = m_dcaMapping->channelsForDCA(dca); + QList channels = getChannelsForDCA(cue, dca); for (int ch : channels) { targetChannels.insert(ch); } - QList buses = m_dcaMapping->busesForDCA(dca); + QList buses = getBusesForDCA(cue, dca); for (int bus : buses) { targetBuses.insert(bus); } @@ -293,6 +318,7 @@ QJsonObject PlaybackEngine::filterParametersForDCAs(const QJsonObject& params, static QRegularExpression busRegex("^/bus/(\\d+)/"); static QRegularExpression dcaFaderRegex("^/dca/\\d+/fader$"); + const QJsonObject& params = cue.parameters(); for (auto it = params.begin(); it != params.end(); ++it) { const QString& path = it.key(); diff --git a/src/core/PlaybackEngine.h b/src/core/PlaybackEngine.h index bab92d5..37ec134 100644 --- a/src/core/PlaybackEngine.h +++ b/src/core/PlaybackEngine.h @@ -59,6 +59,7 @@ class PlaybackEngine : public QObject { void goToNumber(double num); void previous(); void next(); + void setStandbyIndex(int index); void executeCue(int index); void executeCueById(const QString& id); @@ -82,7 +83,6 @@ class PlaybackEngine : public QObject { private: void setState(PlaybackState state); void setCurrentIndex(int index); - void setStandbyIndex(int index); void advanceStandby(); void executeCueInternal(const Cue& cue); void applyDCAOverrides(const Cue& cue, const QSet& targetDCAs); @@ -92,9 +92,10 @@ class PlaybackEngine : public QObject { void executeNextMacroChild(); void handleAutoFollow(const Cue& cue); - // DCA filtering helper - QJsonObject filterParametersForDCAs(const QJsonObject& params, - const QSet& targetDCAs) const; + // DCA filtering helpers + QList getChannelsForDCA(const Cue& cue, int dca) const; + QList getBusesForDCA(const Cue& cue, int dca) const; + QJsonObject filterParametersForDCAs(const Cue& cue, const QSet& targetDCAs) const; QSet allDCAs() const; CueList* m_cueList = nullptr; diff --git a/src/io/ProjectFile.h b/src/io/ProjectFile.h index efcc655..afaae36 100644 --- a/src/io/ProjectFile.h +++ b/src/io/ProjectFile.h @@ -10,7 +10,7 @@ class Show; class ProjectFile { public: static constexpr const char* FILE_EXTENSION = ".omproj"; - static constexpr const char* FILE_FILTER = "OpenMix Projects (*.omproj);;All Files (*)"; + static constexpr const char* FILE_FILTER = "OpenMix Project (*.omproj);;All Files (*)"; static bool save(const Show* show, const QString& filePath, QString* errorMsg = nullptr); static bool load(Show* show, const QString& filePath, QString* errorMsg = nullptr); diff --git a/src/main.cpp b/src/main.cpp index 5b36466..f276fde 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include int main(int argc, char* argv[]) { @@ -50,6 +51,8 @@ int main(int argc, char* argv[]) { OpenMix::MainWindow mainWindow(&app); app.setMainWindow(&mainWindow); mainWindow.show(); + QTimer::singleShot(0, &mainWindow, &OpenMix::MainWindow::openConnectionPanel); + QTimer::singleShot(500, &app, &OpenMix::Application::startupScan); return qtApp.exec(); } diff --git a/src/ui/ConsoleDiscoveryWidget.cpp b/src/ui/ConsoleDiscoveryWidget.cpp index a46060d..f345bba 100644 --- a/src/ui/ConsoleDiscoveryWidget.cpp +++ b/src/ui/ConsoleDiscoveryWidget.cpp @@ -62,7 +62,7 @@ void ConsoleDiscoveryWidget::setupUi() { // status label m_statusLabel = new QLabel(this); m_statusLabel->setStyleSheet("color: gray; font-style: italic;"); - m_statusLabel->setText(tr("Click 'Scan Network' to find mixers")); + m_statusLabel->setText(tr("Click 'Scan Network' to find OSC-enabled mixers")); layout->addWidget(m_statusLabel); } diff --git a/src/ui/CueItemDelegates.cpp b/src/ui/CueItemDelegates.cpp index dd689bd..3793533 100644 --- a/src/ui/CueItemDelegates.cpp +++ b/src/ui/CueItemDelegates.cpp @@ -16,6 +16,21 @@ namespace OpenMix { CueNumberDelegate::CueNumberDelegate(CueList* cueList, QObject* parent) : QStyledItemDelegate(parent), m_cueList(cueList) {} +void CueNumberDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const { + QVariant bgData = index.data(Qt::BackgroundRole); + if (bgData.isValid()) { + painter->fillRect(option.rect, bgData.value()); + } + + QStyleOptionViewItem opt = option; + if (bgData.isValid()) { + opt.backgroundBrush = bgData.value(); + } + + QStyledItemDelegate::paint(painter, opt, index); +} + QWidget* CueNumberDelegate::createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const { Q_UNUSED(option); @@ -102,6 +117,21 @@ bool CueNumberDelegate::eventFilter(QObject* object, QEvent* event) { CueTypeDelegate::CueTypeDelegate(QObject* parent) : QStyledItemDelegate(parent) {} +void CueTypeDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const { + QVariant bgData = index.data(Qt::BackgroundRole); + if (bgData.isValid()) { + painter->fillRect(option.rect, bgData.value()); + } + + QStyleOptionViewItem opt = option; + if (bgData.isValid()) { + opt.backgroundBrush = bgData.value(); + } + + QStyledItemDelegate::paint(painter, opt, index); +} + QWidget* CueTypeDelegate::createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const { Q_UNUSED(option); @@ -180,6 +210,21 @@ bool CueTypeDelegate::eventFilter(QObject* object, QEvent* event) { CueTextDelegate::CueTextDelegate(QObject* parent) : QStyledItemDelegate(parent) {} +void CueTextDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const { + QVariant bgData = index.data(Qt::BackgroundRole); + if (bgData.isValid()) { + painter->fillRect(option.rect, bgData.value()); + } + + QStyleOptionViewItem opt = option; + if (bgData.isValid()) { + opt.backgroundBrush = bgData.value(); + } + + QStyledItemDelegate::paint(painter, opt, index); +} + QWidget* CueTextDelegate::createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const { Q_UNUSED(option); diff --git a/src/ui/CueItemDelegates.h b/src/ui/CueItemDelegates.h index c339963..729959b 100644 --- a/src/ui/CueItemDelegates.h +++ b/src/ui/CueItemDelegates.h @@ -2,6 +2,7 @@ #include #include +#include #include namespace OpenMix { @@ -14,6 +15,9 @@ class CueNumberDelegate : public QStyledItemDelegate { public: explicit CueNumberDelegate(CueList* cueList, QObject* parent = nullptr); + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const override; @@ -41,6 +45,9 @@ class CueTypeDelegate : public QStyledItemDelegate { public: explicit CueTypeDelegate(QObject* parent = nullptr); + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const override; @@ -67,6 +74,9 @@ class CueTextDelegate : public QStyledItemDelegate { public: explicit CueTextDelegate(QObject* parent = nullptr); + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem& option, const QModelIndex& index) const override; diff --git a/src/ui/CueListView.cpp b/src/ui/CueListView.cpp index 87fe8b4..b60f6d0 100644 --- a/src/ui/CueListView.cpp +++ b/src/ui/CueListView.cpp @@ -61,7 +61,7 @@ void CueListView::setupUi() { m_tableView->setModel(m_proxyModel); // selection & editing - m_tableView->setSelectionBehavior(QAbstractItemView::SelectItems); + m_tableView->setSelectionBehavior(QAbstractItemView::SelectRows); m_tableView->setSelectionMode(QAbstractItemView::SingleSelection); m_tableView->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed | @@ -143,7 +143,7 @@ void CueListView::createActions() { // register with shortcut manager ShortcutManager* sm = m_app->shortcutManager(); - sm->registerAction("edit.duplicateCue", m_duplicateCueAction, QKeySequence()); + sm->registerAction("edit.duplicateCue", m_duplicateCueAction, QKeySequence(Qt::CTRL | Qt::Key_D)); // register filter bar clear action QAction* clearAction = m_filterBar->clearFiltersAction(); @@ -179,6 +179,15 @@ void CueListView::setCurrentCueHighlight(int index) { void CueListView::setStandbyCueHighlight(int index) { m_standbyCueIndex = index; m_model->setStandbyCueIndex(index); + + if (m_app->playbackEngine()->state() == PlaybackState::Stopped && index >= 0) { + QModelIndex sourceIndex = m_model->index(index, 0); + QModelIndex proxyIndex = m_proxyModel->mapFromSource(sourceIndex); + if (proxyIndex.isValid()) { + m_tableView->selectRow(proxyIndex.row()); + m_tableView->scrollTo(proxyIndex, QAbstractItemView::EnsureVisible); + } + } } void CueListView::refreshAll() { @@ -242,8 +251,9 @@ void CueListView::duplicateSelectedCue() { CueList* cueList = m_app->show()->cueList(); Cue original = cueList->at(idx); - // create duplicate w/ next available number + // create duplicate w/ next available number & new ID Cue duplicate = original; + duplicate.regenerateId(); duplicate.setNumber(cueList->nextCueNumber()); // push to undo stack diff --git a/src/ui/CueTableModel.cpp b/src/ui/CueTableModel.cpp index 01509ac..8b8f1ba 100644 --- a/src/ui/CueTableModel.cpp +++ b/src/ui/CueTableModel.cpp @@ -66,9 +66,7 @@ QVariant CueTableModel::data(const QModelIndex& index, int role) const { if (role == Qt::BackgroundRole) { if (row == m_currentIndex) { - return QBrush(Theme::withAlpha(Theme::Colors::AccentGreen, 45)); - } else if (row == m_standbyIndex) { - return QBrush(Theme::withAlpha(Theme::Colors::AccentAmber, 40)); + return QBrush(QColor(34, 197, 94, 160)); } } diff --git a/src/ui/DCAMappingPanel.cpp b/src/ui/DCAMappingPanel.cpp index b32b027..332915a 100644 --- a/src/ui/DCAMappingPanel.cpp +++ b/src/ui/DCAMappingPanel.cpp @@ -1,5 +1,6 @@ #include "DCAMappingPanel.h" #include "app/Application.h" +#include "core/Cue.h" #include "core/DCAMapping.h" #include "core/ShortcutManager.h" #include "core/Show.h" @@ -8,14 +9,16 @@ #include "theme/Icons.h" #include "theme/Theme.h" -#include +#include #include #include #include #include #include #include +#include #include +#include #include #include #include @@ -48,6 +51,40 @@ void DCAMappingPanel::setupUi() { mainLayout->setContentsMargins(8, 8, 8, 8); mainLayout->setSpacing(8); + m_contextHeader = new QWidget(this); + QHBoxLayout* contextLayout = new QHBoxLayout(m_contextHeader); + contextLayout->setContentsMargins(4, 4, 4, 4); + contextLayout->setSpacing(8); + + m_contextLabel = new QLabel(tr("Show Level"), m_contextHeader); + m_contextLabel->setStyleSheet( + QString("font-weight: bold; color: %1;").arg(Theme::Colors::TextPrimary)); + contextLayout->addWidget(m_contextLabel); + + m_useCueMappingCheck = new QCheckBox(tr("Use Cue-Specific Mapping"), m_contextHeader); + m_useCueMappingCheck->setToolTip(tr("Enable to override show-level mapping for this cue only")); + m_useCueMappingCheck->setVisible(false); + connect(m_useCueMappingCheck, &QCheckBox::toggled, this, + &DCAMappingPanel::onUseCueMappingToggled); + contextLayout->addWidget(m_useCueMappingCheck); + + m_copyFromShowButton = new QPushButton(tr("Copy from Show"), m_contextHeader); + m_copyFromShowButton->setToolTip(tr("Copy current show-level mapping to this cue")); + m_copyFromShowButton->setVisible(false); + connect(m_copyFromShowButton, &QPushButton::clicked, this, + &DCAMappingPanel::copyShowMappingToCue); + contextLayout->addWidget(m_copyFromShowButton); + + m_clearCueMappingButton = new QPushButton(tr("Clear Cue Mapping"), m_contextHeader); + m_clearCueMappingButton->setToolTip(tr("Remove cue-specific mapping, revert to show-level")); + m_clearCueMappingButton->setVisible(false); + connect(m_clearCueMappingButton, &QPushButton::clicked, this, + &DCAMappingPanel::clearCueMapping); + contextLayout->addWidget(m_clearCueMappingButton); + + contextLayout->addStretch(); + mainLayout->addWidget(m_contextHeader); + // create actions m_syncFromMixerAction = new QAction(Icons::refresh(), tr("Sync from Mixer"), this); m_syncFromMixerAction->setToolTip(tr("Pull current DCA assignments from connected mixer")); @@ -94,12 +131,12 @@ void DCAMappingPanel::setupUi() { connect(m_syncButton, &QPushButton::clicked, m_syncFromMixerAction, &QAction::trigger); toolbarLayout->addWidget(m_syncButton); - m_savePresetButton = new QPushButton(Icons::download(), tr("Save Preset"), this); + m_savePresetButton = new QPushButton(Icons::download(), tr("Export DCAs"), this); m_savePresetButton->setToolTip(m_savePresetAction->toolTip()); connect(m_savePresetButton, &QPushButton::clicked, m_savePresetAction, &QAction::trigger); toolbarLayout->addWidget(m_savePresetButton); - m_loadPresetButton = new QPushButton(Icons::upload(), tr("Load Preset"), this); + m_loadPresetButton = new QPushButton(Icons::upload(), tr("Import DCAs"), this); m_loadPresetButton->setToolTip(m_loadPresetAction->toolTip()); connect(m_loadPresetButton, &QPushButton::clicked, m_loadPresetAction, &QAction::trigger); toolbarLayout->addWidget(m_loadPresetButton); @@ -169,7 +206,7 @@ void DCAMappingPanel::createChannelSection() { m_channelLayout->addWidget(label, row, colOffset); m_channelLabels.append(label); - QComboBox* combo = new QComboBox(m_channelGroup); + NoScrollComboBox* combo = new NoScrollComboBox(m_channelGroup); combo->addItem(tr("None"), -1); for (int d = 1; d <= m_dcaCount; ++d) { combo->addItem(tr("DCA %1").arg(d), d); @@ -197,6 +234,7 @@ void DCAMappingPanel::createBusSection() { } m_busCombos.clear(); m_busLabels.clear(); + m_busNameEdits.clear(); // header m_busLayout->addWidget(new QLabel(tr("Bus"), m_busGroup), 0, 0); @@ -208,12 +246,32 @@ void DCAMappingPanel::createBusSection() { int row = ((bus - 1) % 8) + 1; colOffset = ((bus - 1) / 8) * 3; - QLabel* label = new QLabel(tr("Bus %1").arg(bus), m_busGroup); - label->setMinimumWidth(60); - m_busLayout->addWidget(label, row, colOffset); + // stacked label + line edit in a container + QWidget* nameContainer = new QWidget(m_busGroup); + QVBoxLayout* nameLayout = new QVBoxLayout(nameContainer); + nameLayout->setContentsMargins(0, 0, 0, 0); + nameLayout->setSpacing(0); + + QLabel* label = new QLabel(busDisplayName(bus), nameContainer); + label->setMinimumWidth(80); + label->setProperty("busIndex", bus); + label->installEventFilter(this); + label->setToolTip(tr("Double-click to rename")); + nameLayout->addWidget(label); m_busLabels.append(label); - QComboBox* combo = new QComboBox(m_busGroup); + QLineEdit* edit = new QLineEdit(nameContainer); + edit->setVisible(false); + edit->setProperty("busIndex", bus); + edit->installEventFilter(this); + nameLayout->addWidget(edit); + m_busNameEdits.append(edit); + + connect(edit, &QLineEdit::returnPressed, [this, bus]() { finishBusNameEdit(bus); }); + + m_busLayout->addWidget(nameContainer, row, colOffset); + + NoScrollComboBox* combo = new NoScrollComboBox(m_busGroup); combo->addItem(tr("None"), -1); for (int d = 1; d <= m_dcaCount; ++d) { combo->addItem(tr("DCA %1").arg(d), d); @@ -257,9 +315,38 @@ void DCAMappingPanel::populateFromMapping() { m_updatingUi = true; + QMap> channelMap; + QMap> busMap; + + if (m_showingCueMapping && m_currentCue && m_currentCue->hasCustomDCAMapping()) { + channelMap = m_currentCue->dcaChannelMapping(); + busMap = m_currentCue->dcaBusMapping(); + } else { + channelMap = m_mapping->channelAssignments(); + busMap = m_mapping->busAssignments(); + } + + // build reverse lookup from channel -> DCA + QMap channelToDCA; + for (auto it = channelMap.constBegin(); it != channelMap.constEnd(); ++it) { + int dca = it.key(); + for (int ch : it.value()) { + channelToDCA[ch] = dca; + } + } + + // build reverse lookup from bus -> DCA + QMap busToDCA; + for (auto it = busMap.constBegin(); it != busMap.constEnd(); ++it) { + int dca = it.key(); + for (int bus : it.value()) { + busToDCA[bus] = dca; + } + } + // set channel combos for (int ch = 1; ch <= m_channelCombos.size(); ++ch) { - int dca = m_mapping->dcaForChannel(ch); + int dca = channelToDCA.value(ch, -1); int index = 0; if (dca > 0 && dca <= m_dcaCount) { index = dca; // DCA 1 is at index 1, etc. @@ -269,7 +356,7 @@ void DCAMappingPanel::populateFromMapping() { // set bus combos for (int bus = 1; bus <= m_busCombos.size(); ++bus) { - int dca = m_mapping->dcaForBus(bus); + int dca = busToDCA.value(bus, -1); int index = 0; if (dca > 0 && dca <= m_dcaCount) { index = dca; @@ -286,19 +373,49 @@ void DCAMappingPanel::updateComboItemStates() { if (!m_mapping) return; + // determine which mapping to use + QMap> channelMap; + QMap> busMap; + + if (m_showingCueMapping && m_currentCue && m_currentCue->hasCustomDCAMapping()) { + channelMap = m_currentCue->dcaChannelMapping(); + busMap = m_currentCue->dcaBusMapping(); + } else { + channelMap = m_mapping->channelAssignments(); + busMap = m_mapping->busAssignments(); + } + + // build reverse lookup from channel -> DCA + QMap channelToDCA; + for (auto it = channelMap.constBegin(); it != channelMap.constEnd(); ++it) { + int dca = it.key(); + for (int ch : it.value()) { + channelToDCA[ch] = dca; + } + } + + // build reverse lookup from bus -> DCA + QMap busToDCA; + for (auto it = busMap.constBegin(); it != busMap.constEnd(); ++it) { + int dca = it.key(); + for (int bus : it.value()) { + busToDCA[bus] = dca; + } + } + // count assignments per DCA QMap channelCounts; QMap busCounts; for (int d = 1; d <= m_dcaCount; ++d) { - channelCounts[d] = m_mapping->channelsForDCA(d).size(); - busCounts[d] = m_mapping->busesForDCA(d).size(); + channelCounts[d] = channelMap.value(d).size(); + busCounts[d] = busMap.value(d).size(); } // update channel labels w/ assignment status const QString assignedStyle = QString("color: %1; font-weight: bold;").arg(Theme::Colors::AccentBlue); for (int ch = 1; ch <= m_channelLabels.size(); ++ch) { - int dca = m_mapping->dcaForChannel(ch); + int dca = channelToDCA.value(ch, -1); QLabel* label = m_channelLabels[ch - 1]; if (dca > 0) { @@ -314,17 +431,17 @@ void DCAMappingPanel::updateComboItemStates() { // update bus labels w/ assignment status for (int bus = 1; bus <= m_busLabels.size(); ++bus) { - int dca = m_mapping->dcaForBus(bus); + int dca = busToDCA.value(bus, -1); QLabel* label = m_busLabels[bus - 1]; if (dca > 0) { - label->setText(tr("Bus %1 [%2]").arg(bus).arg(dca)); + label->setText(tr("%1 [%2]").arg(busDisplayName(bus)).arg(dca)); label->setStyleSheet(assignedStyle); - label->setToolTip(tr("Assigned to DCA %1 - locked from other DCAs").arg(dca)); + label->setToolTip(tr("Assigned to DCA %1 — double-click to rename").arg(dca)); } else { - label->setText(tr("Bus %1").arg(bus)); + label->setText(busDisplayName(bus)); label->setStyleSheet(""); - label->setToolTip(tr("Not assigned to any DCA")); + label->setToolTip(tr("Double-click to rename")); } } @@ -482,7 +599,7 @@ void DCAMappingPanel::saveMappingPreset() { QFile file(filePath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { QMessageBox::warning(m_app->mainWindow(), tr("Error"), - tr("Failed to save preset: %1").arg(file.errorString())); + tr("Failed to export: %1").arg(file.errorString())); return; } @@ -504,7 +621,7 @@ void DCAMappingPanel::loadMappingPreset() { QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { QMessageBox::warning(m_app->mainWindow(), tr("Error"), - tr("Failed to load preset: %1").arg(file.errorString())); + tr("Failed to load: %1").arg(file.errorString())); return; } @@ -531,27 +648,59 @@ void DCAMappingPanel::clearAllMappings() { } void DCAMappingPanel::onChannelDCAChanged(int channel, int dca) { - if (!m_mapping) - return; + if (m_showingCueMapping && m_currentCue) { + // editing cue-specific mapping + QMap> channelMap = m_currentCue->dcaChannelMapping(); - if (dca < 0) { - m_mapping->clearChannelFromAllDCAs(channel); - } else { - m_mapping->assignChannelToDCA(channel, dca); + // remove from any existing DCA + for (auto it = channelMap.begin(); it != channelMap.end(); ++it) { + it.value().removeAll(channel); + } + + // add to new DCA + if (dca > 0) { + channelMap[dca].append(channel); + } + + m_currentCue->setDCAChannelMapping(channelMap); + m_app->show()->setModified(true); + } else if (m_mapping) { + // editing show-level mapping + if (dca < 0) { + m_mapping->clearChannelFromAllDCAs(channel); + } else { + m_mapping->assignChannelToDCA(channel, dca); + } + m_app->show()->setModified(true); } - m_app->show()->setModified(true); } void DCAMappingPanel::onBusDCAChanged(int bus, int dca) { - if (!m_mapping) - return; + if (m_showingCueMapping && m_currentCue) { + // editing cue-specific mapping + QMap> busMap = m_currentCue->dcaBusMapping(); - if (dca < 0) { - m_mapping->clearBusFromAllDCAs(bus); - } else { - m_mapping->assignBusToDCA(bus, dca); + // remove from any existing DCA + for (auto it = busMap.begin(); it != busMap.end(); ++it) { + it.value().removeAll(bus); + } + + // add to new DCA + if (dca > 0) { + busMap[dca].append(bus); + } + + m_currentCue->setDCABusMapping(busMap); + m_app->show()->setModified(true); + } else if (m_mapping) { + // editing show-level mapping + if (dca < 0) { + m_mapping->clearBusFromAllDCAs(bus); + } else { + m_mapping->assignBusToDCA(bus, dca); + } + m_app->show()->setModified(true); } - m_app->show()->setModified(true); } void DCAMappingPanel::onMixerConnected() { @@ -565,4 +714,173 @@ void DCAMappingPanel::onMixerDisconnected() { m_syncFromMixerAction->setEnabled(false); } +void DCAMappingPanel::setCurrentCue(Cue* cue) { + m_currentCue = cue; + + if (cue) { + // check if cue has custom mapping + m_showingCueMapping = cue->hasCustomDCAMapping(); + m_useCueMappingCheck->blockSignals(true); + m_useCueMappingCheck->setChecked(m_showingCueMapping); + m_useCueMappingCheck->blockSignals(false); + } else { + m_showingCueMapping = false; + } + + updateContextHeader(); + populateFromMapping(); +} + +void DCAMappingPanel::clearCurrentCue() { + m_currentCue = nullptr; + m_showingCueMapping = false; + updateContextHeader(); + populateFromMapping(); +} + +void DCAMappingPanel::updateContextHeader() { + if (m_currentCue) { + // show cue context + QString cueText = QString("Cue %1").arg(m_currentCue->number(), 0, 'f', 1); + if (!m_currentCue->name().isEmpty()) { + cueText += QString(": %1").arg(m_currentCue->name()); + } + m_contextLabel->setText(cueText); + + m_useCueMappingCheck->setVisible(true); + m_copyFromShowButton->setVisible(m_showingCueMapping); + m_clearCueMappingButton->setVisible(m_showingCueMapping && + m_currentCue->hasCustomDCAMapping()); + } else { + // show-level context + m_contextLabel->setText(tr("Show Level")); + m_useCueMappingCheck->setVisible(false); + m_copyFromShowButton->setVisible(false); + m_clearCueMappingButton->setVisible(false); + } +} + +void DCAMappingPanel::onUseCueMappingToggled(bool enabled) { + if (!m_currentCue) + return; + + m_showingCueMapping = enabled; + + if (enabled && !m_currentCue->hasCustomDCAMapping()) { + // copy show mapping as starting point + m_currentCue->copyDCAMappingFrom(m_mapping); + m_app->show()->setModified(true); + } + + updateContextHeader(); + populateFromMapping(); +} + +void DCAMappingPanel::copyShowMappingToCue() { + if (!m_currentCue || !m_mapping) + return; + + m_currentCue->copyDCAMappingFrom(m_mapping); + m_app->show()->setModified(true); + populateFromMapping(); +} + +void DCAMappingPanel::clearCueMapping() { + if (!m_currentCue) + return; + + m_currentCue->clearCustomDCAMapping(); + m_showingCueMapping = false; + + m_useCueMappingCheck->blockSignals(true); + m_useCueMappingCheck->setChecked(false); + m_useCueMappingCheck->blockSignals(false); + + m_app->show()->setModified(true); + updateContextHeader(); + populateFromMapping(); +} + +QString DCAMappingPanel::busDisplayName(int bus) const { + if (m_mapping) { + QString name = m_mapping->busName(bus); + if (!name.isEmpty()) + return name; + } + return tr("Bus %1").arg(bus); +} + +bool DCAMappingPanel::eventFilter(QObject* obj, QEvent* event) { + if (event->type() == QEvent::MouseButtonDblClick) { + QLabel* label = qobject_cast(obj); + if (label && label->property("busIndex").isValid()) { + int bus = label->property("busIndex").toInt(); + startBusNameEdit(bus); + return true; + } + } + if (event->type() == QEvent::KeyPress) { + QLineEdit* edit = qobject_cast(obj); + if (edit && edit->property("busIndex").isValid()) { + QKeyEvent* ke = static_cast(event); + if (ke->key() == Qt::Key_Escape) { + int bus = edit->property("busIndex").toInt(); + cancelBusNameEdit(bus); + return true; + } + } + } + if (event->type() == QEvent::FocusOut) { + QLineEdit* edit = qobject_cast(obj); + if (edit && edit->property("busIndex").isValid()) { + int bus = edit->property("busIndex").toInt(); + finishBusNameEdit(bus); + return false; + } + } + return QWidget::eventFilter(obj, event); +} + +void DCAMappingPanel::startBusNameEdit(int bus) { + if (bus < 1 || bus > m_busLabels.size()) + return; + + QLabel* label = m_busLabels[bus - 1]; + QLineEdit* edit = m_busNameEdits[bus - 1]; + + label->setVisible(false); + edit->setVisible(true); + edit->setText(m_mapping ? m_mapping->busName(bus) : QString()); + edit->setPlaceholderText(tr("Bus %1").arg(bus)); + edit->setFocus(); + edit->selectAll(); +} + +void DCAMappingPanel::finishBusNameEdit(int bus) { + if (bus < 1 || bus > m_busNameEdits.size()) + return; + + QLineEdit* edit = m_busNameEdits[bus - 1]; + if (!edit->isVisible()) + return; + QString name = edit->text().trimmed(); + + if (m_mapping) { + m_mapping->setBusName(bus, name); + m_app->show()->setModified(true); + } + + edit->setVisible(false); + m_busLabels[bus - 1]->setVisible(true); + updateComboItemStates(); +} + +void DCAMappingPanel::cancelBusNameEdit(int bus) { + if (bus < 1 || bus > m_busNameEdits.size()) + return; + + m_busNameEdits[bus - 1]->setVisible(false); + m_busLabels[bus - 1]->setVisible(true); +} + } // namespace OpenMix diff --git a/src/ui/DCAMappingPanel.h b/src/ui/DCAMappingPanel.h index 8915d00..92dad72 100644 --- a/src/ui/DCAMappingPanel.h +++ b/src/ui/DCAMappingPanel.h @@ -1,9 +1,15 @@ #pragma once #include +#include +#include +#include +#include +#include +#include #include -class QComboBox; +class QCheckBox; class QGridLayout; class QGroupBox; class QLabel; @@ -12,7 +18,40 @@ class QScrollArea; namespace OpenMix { +class NoScrollComboBox : public QComboBox { + Q_OBJECT + public: + using QComboBox::QComboBox; + + protected: + void wheelEvent(QWheelEvent* event) override { event->ignore(); } + + void showPopup() override { + m_popupOpen = true; + QComboBox::showPopup(); + } + + void hidePopup() override { + QComboBox::hidePopup(); + QTimer::singleShot(100, this, [this]() { m_popupOpen = false; }); + } + + void keyPressEvent(QKeyEvent* event) override { + int key = event->key(); + if ((key == Qt::Key_Return || key == Qt::Key_Enter) && !m_popupOpen) { + showPopup(); + event->accept(); + } else { + QComboBox::keyPressEvent(event); + } + } + + private: + bool m_popupOpen = false; +}; + class Application; +class Cue; class DCAMapping; class DCAMappingPanel : public QWidget { @@ -28,12 +67,29 @@ class DCAMappingPanel : public QWidget { void loadMappingPreset(); void clearAllMappings(); + // cue-specific mapping + void setCurrentCue(Cue* cue); + void clearCurrentCue(); + private slots: void onChannelDCAChanged(int channel, int dcaIndex); void onBusDCAChanged(int bus, int dcaIndex); void onMixerConnected(); void onMixerDisconnected(); + // cue mapping slots + void onUseCueMappingToggled(bool enabled); + void copyShowMappingToCue(); + void clearCueMapping(); + + // bus name editing + void startBusNameEdit(int bus); + void finishBusNameEdit(int bus); + void cancelBusNameEdit(int bus); + + protected: + bool eventFilter(QObject* obj, QEvent* event) override; + private: void setupUi(); void createChannelSection(); @@ -41,11 +97,23 @@ class DCAMappingPanel : public QWidget { void updateDCAOptions(); void populateFromMapping(); void updateComboItemStates(); + void updateContextHeader(); + QString busDisplayName(int bus) const; Application* m_app; DCAMapping* m_mapping; + // cue-specific mapping + Cue* m_currentCue = nullptr; + bool m_showingCueMapping = false; + // UI elements + QWidget* m_contextHeader; + QLabel* m_contextLabel; + QCheckBox* m_useCueMappingCheck; + QPushButton* m_copyFromShowButton; + QPushButton* m_clearCueMappingButton; + QScrollArea* m_scrollArea; QWidget* m_scrollContent; @@ -58,6 +126,7 @@ class DCAMappingPanel : public QWidget { QGridLayout* m_busLayout; QVector m_busCombos; QVector m_busLabels; + QVector m_busNameEdits; QPushButton* m_syncButton; QPushButton* m_savePresetButton; diff --git a/src/ui/LogItemDelegate.cpp b/src/ui/LogItemDelegate.cpp new file mode 100644 index 0000000..3255e2d --- /dev/null +++ b/src/ui/LogItemDelegate.cpp @@ -0,0 +1,172 @@ +#include "LogItemDelegate.h" +#include "LogListModel.h" +#include "core/AppLogger.h" +#include +#include +#include + +namespace OpenMix { + +LogItemDelegate::LogItemDelegate(QObject* parent) : QStyledItemDelegate(parent) {} + +void LogItemDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const { + painter->save(); + + // draw background + if (option.state & QStyle::State_Selected) { + painter->fillRect(option.rect, QColor(59, 130, 246, 102)); // rgba(59, 130, 246, 0.4) + } else if (option.state & QStyle::State_MouseOver) { + painter->fillRect(option.rect, QColor(34, 37, 42)); // #22252a + } + + QDateTime timestamp = index.data(LogListModel::TimestampRole).toDateTime(); + int level = index.data(LogListModel::LevelRole).toInt(); + int source = index.data(LogListModel::SourceRole).toInt(); + QString message = index.data(LogListModel::MessageRole).toString(); + + LevelStyle style = styleForLevel(level); + + int x = option.rect.x() + 12; + int y = option.rect.y(); + int height = option.rect.height(); + + // monospace font for timestamp + QFont monoFont("JetBrains Mono", 10); + monoFont.setStyleHint(QFont::Monospace); + if (!QFontInfo(monoFont).fixedPitch()) { + monoFont = QFont("Consolas", 10); + monoFont.setStyleHint(QFont::Monospace); + } + + // timestamp + painter->setFont(monoFont); + painter->setPen(QColor(107, 114, 128)); // #6b7280 - muted + QString timeStr = timestamp.toString("HH:mm:ss.zzz"); + QRect timeRect(x, y, 90, height); + painter->drawText(timeRect, Qt::AlignLeft | Qt::AlignVCenter, timeStr); + x += 98; + + // level badge + QString badge = badgeText(level); + QFont badgeFont = option.font; + badgeFont.setPointSize(9); + badgeFont.setBold(true); + painter->setFont(badgeFont); + QFontMetrics badgeFm(badgeFont); + int badgeWidth = badgeFm.horizontalAdvance(badge) + 12; + int badgeHeight = 18; + int badgeY = y + (height - badgeHeight) / 2; + + // draw badge background + QRect badgeRect(x, badgeY, badgeWidth, badgeHeight); + painter->setPen(Qt::NoPen); + painter->setBrush(style.badgeColor); + painter->setRenderHint(QPainter::Antialiasing); + painter->drawRoundedRect(badgeRect, 4, 4); + + // draw badge text + painter->setPen(style.badgeTextColor); + painter->drawText(badgeRect, Qt::AlignCenter, badge); + x += badgeWidth + 8; + + // source label + QString srcStr = sourceText(source); + QFont srcFont = option.font; + srcFont.setPointSize(10); + painter->setFont(srcFont); + painter->setPen(QColor(156, 163, 175)); // #9ca3af - secondary + QRect srcRect(x, y, 80, height); + painter->drawText(srcRect, Qt::AlignLeft | Qt::AlignVCenter, srcStr); + x += 88; + + // message + QFont msgFont = option.font; + msgFont.setPointSize(11); + painter->setFont(msgFont); + painter->setPen(style.textColor); + int messageWidth = option.rect.right() - x - 12; + QRect msgRect(x, y, messageWidth, height); + QString elidedMsg = painter->fontMetrics().elidedText(message, Qt::ElideRight, messageWidth); + painter->drawText(msgRect, Qt::AlignLeft | Qt::AlignVCenter, elidedMsg); + + painter->restore(); +} + +QSize LogItemDelegate::sizeHint(const QStyleOptionViewItem& option, + const QModelIndex& index) const { + Q_UNUSED(index) + return QSize(option.rect.width(), 36); +} + +LogItemDelegate::LevelStyle LogItemDelegate::styleForLevel(int level) const { + LevelStyle style; + + switch (static_cast(level)) { + case LogLevel::Debug: + style.textColor = QColor(156, 163, 175); // #9ca3af + style.badgeColor = QColor(107, 114, 128); // #6b7280 + style.badgeTextColor = QColor(255, 255, 255); + break; + case LogLevel::Info: + style.textColor = QColor(240, 241, 243); // #f0f1f3 + style.badgeColor = QColor(59, 130, 246); // #3b82f6 + style.badgeTextColor = QColor(255, 255, 255); + break; + case LogLevel::Warning: + style.textColor = QColor(245, 158, 11); // #f59e0b + style.badgeColor = QColor(245, 158, 11); // #f59e0b + style.badgeTextColor = QColor(0, 0, 0); + break; + case LogLevel::Error: + style.textColor = QColor(239, 68, 68); // #ef4444 + style.badgeColor = QColor(239, 68, 68); // #ef4444 + style.badgeTextColor = QColor(255, 255, 255); + break; + case LogLevel::Critical: + style.textColor = QColor(255, 255, 255); + style.badgeColor = QColor(185, 28, 28); // #b91c1c - darker red + style.badgeTextColor = QColor(255, 255, 255); + break; + } + + return style; +} + +QString LogItemDelegate::badgeText(int level) const { + switch (static_cast(level)) { + case LogLevel::Debug: + return "DBG"; + case LogLevel::Info: + return "INF"; + case LogLevel::Warning: + return "WRN"; + case LogLevel::Error: + return "ERR"; + case LogLevel::Critical: + return "CRT"; + } + return "???"; +} + +QString LogItemDelegate::sourceText(int source) const { + switch (static_cast(source)) { + case LogSource::Connection: + return "Connection"; + case LogSource::Protocol: + return "Protocol"; + case LogSource::Playback: + return "Playback"; + case LogSource::UI: + return "UI"; + case LogSource::System: + return "System"; + case LogSource::MIDI: + return "MIDI"; + case LogSource::Discovery: + return "Discovery"; + } + return "Unknown"; +} + +} // namespace OpenMix diff --git a/src/ui/LogItemDelegate.h b/src/ui/LogItemDelegate.h new file mode 100644 index 0000000..616f028 --- /dev/null +++ b/src/ui/LogItemDelegate.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +namespace OpenMix { + +class LogItemDelegate : public QStyledItemDelegate { + Q_OBJECT + + public: + explicit LogItemDelegate(QObject* parent = nullptr); + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + + QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; + + private: + struct LevelStyle { + QColor textColor; + QColor badgeColor; + QColor badgeTextColor; + }; + + LevelStyle styleForLevel(int level) const; + QString badgeText(int level) const; + QString sourceText(int source) const; +}; + +} // namespace OpenMix diff --git a/src/ui/LogListModel.cpp b/src/ui/LogListModel.cpp new file mode 100644 index 0000000..68716dd --- /dev/null +++ b/src/ui/LogListModel.cpp @@ -0,0 +1,165 @@ +#include "LogListModel.h" + +namespace OpenMix { + +LogListModel::LogListModel(AppLogger* logger, QObject* parent) + : QAbstractListModel(parent), m_logger(logger) { + if (m_logger) { + connect(m_logger, &AppLogger::entryAdded, this, &LogListModel::onEntryAdded); + connect(m_logger, &AppLogger::entriesBatchAdded, this, &LogListModel::onEntriesBatchAdded); + connect(m_logger, &AppLogger::logCleared, this, &LogListModel::onLogCleared); + + rebuildFilteredEntries(); + } +} + +int LogListModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) { + return 0; + } + return m_filteredEntries.size(); +} + +QVariant LogListModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= m_filteredEntries.size()) { + return {}; + } + + const LogEntry& entry = m_filteredEntries.at(index.row()); + + switch (role) { + case Qt::DisplayRole: + case MessageRole: + return entry.message; + case TimestampRole: + return entry.timestamp; + case LevelRole: + return static_cast(entry.level); + case SourceRole: + return static_cast(entry.source); + case MetadataRole: + return entry.metadata; + case EntryRole: + return QVariant::fromValue(entry); + } + + return {}; +} + +QHash LogListModel::roleNames() const { + QHash names = QAbstractListModel::roleNames(); + names[TimestampRole] = "timestamp"; + names[LevelRole] = "level"; + names[SourceRole] = "source"; + names[MessageRole] = "message"; + names[MetadataRole] = "metadata"; + names[EntryRole] = "entry"; + return names; +} + +void LogListModel::setLevelFilter(LogLevel minLevel) { + if (m_levelFilter != minLevel) { + m_levelFilter = minLevel; + rebuildFilteredEntries(); + } +} + +void LogListModel::setSourceFilter(LogSource source, bool enabled) { + if (m_sourceFilter != source || m_sourceFilterEnabled != enabled) { + m_sourceFilter = source; + m_sourceFilterEnabled = enabled; + rebuildFilteredEntries(); + } +} + +void LogListModel::setSearchText(const QString& text) { + if (m_searchText != text) { + m_searchText = text; + if (!text.isEmpty()) { + m_searchRegex = QRegularExpression(QRegularExpression::escape(text), + QRegularExpression::CaseInsensitiveOption); + } else { + m_searchRegex = QRegularExpression(); + } + rebuildFilteredEntries(); + } +} + +LogEntry LogListModel::entryAt(int row) const { + if (row >= 0 && row < m_filteredEntries.size()) { + return m_filteredEntries.at(row); + } + return {}; +} + +void LogListModel::refresh() { rebuildFilteredEntries(); } + +void LogListModel::onEntryAdded(const LogEntry& entry) { + if (matchesFilter(entry)) { + int row = m_filteredEntries.size(); + beginInsertRows(QModelIndex(), row, row); + m_filteredEntries.append(entry); + endInsertRows(); + } +} + +void LogListModel::onEntriesBatchAdded(const QVector& entries) { + QVector matching; + for (const LogEntry& entry : entries) { + if (matchesFilter(entry)) { + matching.append(entry); + } + } + + if (!matching.isEmpty()) { + int startRow = m_filteredEntries.size(); + beginInsertRows(QModelIndex(), startRow, startRow + matching.size() - 1); + m_filteredEntries.append(matching); + endInsertRows(); + } +} + +void LogListModel::onLogCleared() { + beginResetModel(); + m_filteredEntries.clear(); + endResetModel(); +} + +void LogListModel::rebuildFilteredEntries() { + beginResetModel(); + m_filteredEntries.clear(); + + if (m_logger) { + QVector all = m_logger->allEntries(); + for (const LogEntry& entry : all) { + if (matchesFilter(entry)) { + m_filteredEntries.append(entry); + } + } + } + + endResetModel(); +} + +bool LogListModel::matchesFilter(const LogEntry& entry) const { + // level filter + if (static_cast(entry.level) < static_cast(m_levelFilter)) { + return false; + } + + // source filter + if (m_sourceFilterEnabled && entry.source != m_sourceFilter) { + return false; + } + + // search text filter + if (!m_searchText.isEmpty()) { + if (!entry.message.contains(m_searchRegex)) { + return false; + } + } + + return true; +} + +} // namespace OpenMix diff --git a/src/ui/LogListModel.h b/src/ui/LogListModel.h new file mode 100644 index 0000000..6b27e36 --- /dev/null +++ b/src/ui/LogListModel.h @@ -0,0 +1,65 @@ +#pragma once + +#include "core/AppLogger.h" +#include +#include + +namespace OpenMix { + +class LogListModel : public QAbstractListModel { + Q_OBJECT + + public: + enum Roles { + TimestampRole = Qt::UserRole + 1, + LevelRole, + SourceRole, + MessageRole, + MetadataRole, + EntryRole + }; + + explicit LogListModel(AppLogger* logger, QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + // filtering + void setLevelFilter(LogLevel minLevel); + LogLevel levelFilter() const { return m_levelFilter; } + + void setSourceFilter(LogSource source, bool enabled); + bool sourceFilterEnabled() const { return m_sourceFilterEnabled; } + LogSource sourceFilter() const { return m_sourceFilter; } + + void setSearchText(const QString& text); + QString searchText() const { return m_searchText; } + + // get entry at index (filtered) + LogEntry entryAt(int row) const; + + // refresh from logger + void refresh(); + + public slots: + void onEntryAdded(const LogEntry& entry); + void onEntriesBatchAdded(const QVector& entries); + void onLogCleared(); + + private: + void rebuildFilteredEntries(); + bool matchesFilter(const LogEntry& entry) const; + + AppLogger* m_logger; + + QVector m_filteredEntries; + + LogLevel m_levelFilter = LogLevel::Debug; + LogSource m_sourceFilter = LogSource::System; + bool m_sourceFilterEnabled = false; + QString m_searchText; + QRegularExpression m_searchRegex; +}; + +} // namespace OpenMix diff --git a/src/ui/LogViewerDialog.cpp b/src/ui/LogViewerDialog.cpp new file mode 100644 index 0000000..b364c36 --- /dev/null +++ b/src/ui/LogViewerDialog.cpp @@ -0,0 +1,196 @@ +#include "LogViewerDialog.h" +#include "LogItemDelegate.h" +#include "LogListModel.h" +#include "core/AppLogger.h" +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +LogViewerDialog::LogViewerDialog(AppLogger* logger, QWidget* parent) + : QDialog(parent), m_logger(logger) { + setWindowTitle(tr("Application Log")); + setMinimumSize(800, 500); + resize(1000, 600); + + m_model = new LogListModel(logger, this); + m_delegate = new LogItemDelegate(this); + + setupUi(); + + connect(m_logger, &AppLogger::entryAdded, this, &LogViewerDialog::onEntryAdded); +} + +LogViewerDialog::~LogViewerDialog() = default; + +void LogViewerDialog::setupUi() { + QVBoxLayout* mainLayout = new QVBoxLayout(this); + 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)); + m_levelCombo->addItem(tr("Info"), static_cast(LogLevel::Info)); + m_levelCombo->addItem(tr("Warning"), static_cast(LogLevel::Warning)); + m_levelCombo->addItem(tr("Error"), static_cast(LogLevel::Error)); + m_levelCombo->addItem(tr("Critical"), static_cast(LogLevel::Critical)); + m_levelCombo->setCurrentIndex(0); + m_levelCombo->setMinimumWidth(100); + 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); + m_sourceCombo->addItem(tr("Connection"), static_cast(LogSource::Connection)); + m_sourceCombo->addItem(tr("Protocol"), static_cast(LogSource::Protocol)); + m_sourceCombo->addItem(tr("Playback"), static_cast(LogSource::Playback)); + m_sourceCombo->addItem(tr("UI"), static_cast(LogSource::UI)); + m_sourceCombo->addItem(tr("System"), static_cast(LogSource::System)); + m_sourceCombo->addItem(tr("MIDI"), static_cast(LogSource::MIDI)); + m_sourceCombo->addItem(tr("Discovery"), static_cast(LogSource::Discovery)); + m_sourceCombo->setCurrentIndex(0); + m_sourceCombo->setMinimumWidth(120); + 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::stateChanged, this, + &LogViewerDialog::onAutoScrollChanged); + + filterLayout->addWidget(levelLabel); + filterLayout->addWidget(m_levelCombo); + filterLayout->addWidget(sourceLabel); + filterLayout->addWidget(m_sourceCombo); + filterLayout->addWidget(m_searchEdit, 1); + filterLayout->addWidget(m_autoScrollCheck); + + mainLayout->addLayout(filterLayout); + + // log view + m_logView = new QListView(this); + m_logView->setModel(m_model); + m_logView->setItemDelegate(m_delegate); + m_logView->setSelectionMode(QAbstractItemView::SingleSelection); + m_logView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_logView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_logView->setUniformItemSizes(true); + m_logView->setObjectName("LogListView"); + + mainLayout->addWidget(m_logView, 1); + + // button bar + QHBoxLayout* buttonLayout = new QHBoxLayout(); + buttonLayout->setSpacing(8); + + m_clearButton = new QPushButton(tr("Clear"), this); + m_clearButton->setProperty("role", "secondary"); + connect(m_clearButton, &QPushButton::clicked, this, &LogViewerDialog::onClearClicked); + + m_exportButton = new QPushButton(tr("Export..."), this); + m_exportButton->setProperty("role", "secondary"); + connect(m_exportButton, &QPushButton::clicked, this, &LogViewerDialog::onExportClicked); + + m_closeButton = new QPushButton(tr("Close"), this); + connect(m_closeButton, &QPushButton::clicked, this, &QDialog::accept); + + buttonLayout->addWidget(m_clearButton); + buttonLayout->addWidget(m_exportButton); + buttonLayout->addStretch(); + buttonLayout->addWidget(m_closeButton); + + mainLayout->addLayout(buttonLayout); + + // scroll to bottom on initial load + scrollToBottom(); +} + +void LogViewerDialog::onLevelFilterChanged(int index) { + int level = m_levelCombo->itemData(index).toInt(); + m_model->setLevelFilter(static_cast(level)); +} + +void LogViewerDialog::onSourceFilterChanged(int index) { + int source = m_sourceCombo->itemData(index).toInt(); + if (source < 0) { + m_model->setSourceFilter(LogSource::System, false); + } else { + m_model->setSourceFilter(static_cast(source), true); + } +} + +void LogViewerDialog::onSearchTextChanged(const QString& text) { m_model->setSearchText(text); } + +void LogViewerDialog::onAutoScrollChanged(int state) { Q_UNUSED(state) } + +void LogViewerDialog::onClearClicked() { + QMessageBox::StandardButton reply = QMessageBox::question( + this, tr("Clear Log"), tr("Are you sure you want to clear all log entries?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (reply == QMessageBox::Yes) { + m_logger->clear(); + } +} + +void LogViewerDialog::onExportClicked() { + QString filter = tr("JSON Files (*.json);;CSV Files (*.csv);;All Files (*)"); + QString filePath = QFileDialog::getSaveFileName(this, tr("Export Log"), QString(), filter); + + if (filePath.isEmpty()) { + return; + } + + bool success = false; + if (filePath.endsWith(".csv", Qt::CaseInsensitive)) { + success = m_logger->exportToCSV(filePath); + } else { + if (!filePath.endsWith(".json", Qt::CaseInsensitive)) { + filePath += ".json"; + } + success = m_logger->exportToFile(filePath); + } + + if (success) { + QMessageBox::information(this, tr("Export Complete"), + tr("Log exported successfully to:\n%1").arg(filePath)); + } else { + QMessageBox::warning(this, tr("Export Failed"), + tr("Failed to export log to:\n%1").arg(filePath)); + } +} + +void LogViewerDialog::onEntryAdded() { + if (m_autoScrollCheck->isChecked()) { + scrollToBottom(); + } +} + +void LogViewerDialog::scrollToBottom() { + QScrollBar* vbar = m_logView->verticalScrollBar(); + if (vbar) { + vbar->setValue(vbar->maximum()); + } +} + +} // namespace OpenMix diff --git a/src/ui/LogViewerDialog.h b/src/ui/LogViewerDialog.h new file mode 100644 index 0000000..990511f --- /dev/null +++ b/src/ui/LogViewerDialog.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +class AppLogger; +class LogListModel; +class LogItemDelegate; + +class LogViewerDialog : public QDialog { + Q_OBJECT + + public: + explicit LogViewerDialog(AppLogger* logger, QWidget* parent = nullptr); + ~LogViewerDialog() override; + + private slots: + void onLevelFilterChanged(int index); + void onSourceFilterChanged(int index); + void onSearchTextChanged(const QString& text); + void onAutoScrollChanged(int state); + void onClearClicked(); + void onExportClicked(); + void onEntryAdded(); + + private: + void setupUi(); + void scrollToBottom(); + + AppLogger* m_logger; + LogListModel* m_model; + LogItemDelegate* m_delegate; + + // filter controls + QComboBox* m_levelCombo; + QComboBox* m_sourceCombo; + QLineEdit* m_searchEdit; + QCheckBox* m_autoScrollCheck; + + // log view + QListView* m_logView; + + // buttons + QPushButton* m_clearButton; + QPushButton* m_exportButton; + QPushButton* m_closeButton; +}; + +} // namespace OpenMix diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3343f4a..83ae59f 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -6,9 +6,11 @@ #include "CueListView.h" #include "CueTableModel.h" #include "DCAMappingPanel.h" +#include "LogViewerDialog.h" #include "MixerFeedbackPanel.h" #include "PopOutWindow.h" #include "app/Application.h" +#include "core/AppLogger.h" #include "core/CueList.h" #include "core/CueValidator.h" #include "core/PlaybackEngine.h" @@ -186,6 +188,11 @@ void MainWindow::createActions() { m_showConnectionAction->setToolTip(tr("Show/hide connection panel (F7)")); connect(m_showConnectionAction, &QAction::triggered, this, &MainWindow::toggleConnectionPanel); + m_showLogViewerAction = new QAction(tr("Application &Log..."), this); + m_showLogViewerAction->setShortcut(Qt::Key_F8); + m_showLogViewerAction->setToolTip(tr("Show application log (F8)")); + connect(m_showLogViewerAction, &QAction::triggered, this, &MainWindow::showLogViewerDialog); + // settings actions m_keyboardShortcutsAction = new QAction(tr("Keyboard Shortcuts..."), this); m_keyboardShortcutsAction->setToolTip(tr("Configure keyboard shortcuts")); @@ -241,6 +248,7 @@ void MainWindow::registerShortcuts() { sm->registerAction("view.dcaMapping", m_showDCAMappingAction, QKeySequence(Qt::Key_F5)); sm->registerAction("view.mixerFeedback", m_showMixerFeedbackAction, QKeySequence(Qt::Key_F6)); sm->registerAction("view.connection", m_showConnectionAction, QKeySequence(Qt::Key_F7)); + sm->registerAction("view.logViewer", m_showLogViewerAction, QKeySequence(Qt::Key_F8)); // settings actions sm->registerAction("settings.keyboardShortcuts", m_keyboardShortcutsAction, QKeySequence()); @@ -285,6 +293,8 @@ void MainWindow::createMenus() { m_viewMenu->addAction(m_showDCAMappingAction); m_viewMenu->addAction(m_showMixerFeedbackAction); m_viewMenu->addAction(m_showConnectionAction); + m_viewMenu->addSeparator(); + m_viewMenu->addAction(m_showLogViewerAction); m_settingsMenu = menuBar()->addMenu(tr("&Settings")); m_settingsMenu->addAction(m_keyboardShortcutsAction); @@ -318,11 +328,13 @@ void MainWindow::createToolBars() { void MainWindow::createStatusBar() { m_connectionStatusLabel = new QLabel(tr("Disconnected")); m_cueStatusLabel = new QLabel(tr("No cues")); - m_playbackStatusLabel = new QLabel(tr("Stopped")); + m_currentCueLabel = new QLabel(); + m_nextCueLabel = new QLabel(); statusBar()->addWidget(m_connectionStatusLabel); statusBar()->addWidget(m_cueStatusLabel, 1); - statusBar()->addPermanentWidget(m_playbackStatusLabel); + statusBar()->addPermanentWidget(m_currentCueLabel); + statusBar()->addPermanentWidget(m_nextCueLabel); } void MainWindow::createPopOutWindows() { @@ -404,6 +416,19 @@ void MainWindow::connectSignals() { connect(m_cueListView, &CueListView::cueSelected, m_cueEditor, &CueEditor::setCue); connect(m_cueListView, &CueListView::cueSelected, m_mixerFeedbackPanel, &MixerFeedbackPanel::onActiveCueChanged); + connect(m_cueListView, &CueListView::cueSelected, this, [this](int index) { + if (m_app->playbackEngine()->state() == PlaybackState::Stopped) { + m_app->playbackEngine()->setStandbyIndex(index); + } + }); + connect(m_cueListView, &CueListView::cueSelected, this, [this](int index) { + CueList* cueList = m_app->show()->cueList(); + if (index >= 0 && index < cueList->count()) { + m_dcaMappingPanel->setCurrentCue(&(*cueList)[index]); + } else { + m_dcaMappingPanel->clearCurrentCue(); + } + }); connect(m_cueListView, &CueListView::cueDoubleClicked, [this](int index) { m_app->playbackEngine()->executeCue(index); }); @@ -592,6 +617,12 @@ void MainWindow::go() { m_app->playbackEngine()->go(); } void MainWindow::stopPlayback() { m_app->playbackEngine()->stop(); } +void MainWindow::openConnectionPanel() { + if (!m_connectionPopOut->isVisible()) { + m_connectionPopOut->showAndRestore(); + } +} + void MainWindow::toggleConnectionPanel() { if (m_connectionPopOut->isVisible()) { m_connectionPopOut->hide(); @@ -637,15 +668,24 @@ 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(); + m_currentCueLabel->setText(tr("Current: %1 - %2") + .arg(cue->number(), 0, 'f', 1) + .arg(cue->name().isEmpty() ? tr("(unnamed)") : cue->name())); + } else { + m_currentCueLabel->setText(tr("Current: --")); + } + int standbyIdx = m_app->playbackEngine()->standbyCueIndex(); if (standbyIdx >= 0) { const Cue* cue = m_app->playbackEngine()->standbyCue(); - m_playbackStatusLabel->setText( - tr("Next: %1 - %2") - .arg(cue->number(), 0, 'f', 1) - .arg(cue->name().isEmpty() ? tr("(unnamed)") : cue->name())); + m_nextCueLabel->setText(tr("Next: %1 - %2") + .arg(cue->number(), 0, 'f', 1) + .arg(cue->name().isEmpty() ? tr("(unnamed)") : cue->name())); } else { - m_playbackStatusLabel->setText(tr("End of list")); + m_nextCueLabel->setText(tr("Next: --")); } updateActions(); @@ -773,4 +813,9 @@ void MainWindow::showKeyboardShortcutsDialog() { dialog.exec(); } +void MainWindow::showLogViewerDialog() { + LogViewerDialog dialog(m_app->appLogger(), this); + dialog.exec(); +} + } // namespace OpenMix diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 389dbbf..66bb466 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -29,6 +29,8 @@ class MainWindow : public QMainWindow { explicit MainWindow(Application* app, QWidget* parent = nullptr); ~MainWindow() override; + void openConnectionPanel(); + protected: void closeEvent(QCloseEvent* event) override; void keyPressEvent(QKeyEvent* event) override; @@ -74,6 +76,7 @@ class MainWindow : public QMainWindow { // settings dialogs void showMidiConfigDialog(); void showKeyboardShortcutsDialog(); + void showLogViewerDialog(); // bubble bar interaction void onBubbleButtonClicked(const QString& id, bool checked); @@ -155,6 +158,7 @@ class MainWindow : public QMainWindow { QAction* m_showConnectionAction; QAction* m_showMixerFeedbackAction; QAction* m_showDCAMappingAction; + QAction* m_showLogViewerAction; // settings actions QAction* m_keyboardShortcutsAction; @@ -166,7 +170,8 @@ class MainWindow : public QMainWindow { // status bar QLabel* m_connectionStatusLabel; QLabel* m_cueStatusLabel; - QLabel* m_playbackStatusLabel; + QLabel* m_currentCueLabel; + QLabel* m_nextCueLabel; }; } // namespace OpenMix diff --git a/src/ui/PopOutWindow.cpp b/src/ui/PopOutWindow.cpp index c8fc664..4ec1c32 100644 --- a/src/ui/PopOutWindow.cpp +++ b/src/ui/PopOutWindow.cpp @@ -13,7 +13,7 @@ namespace OpenMix { PopOutWindow::PopOutWindow(const QString& settingsKey, const QString& title, QWidget* parent) : QDialog(parent, - Qt::Tool | Qt::CustomizeWindowHint | Qt::WindowTitleHint | Qt::WindowCloseButtonHint), + Qt::Window | Qt::CustomizeWindowHint | Qt::WindowTitleHint | Qt::WindowCloseButtonHint), m_settingsKey(settingsKey) { setObjectName(Theme::ObjectNames::PopOutWindow); setAttribute(Qt::WA_DeleteOnClose, false);