// Copyright (C) 2021 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 #include "loggingviewer.h" #include "actionmanager/actionmanager.h" #include "coreicons.h" #include "coreplugintr.h" #include "icore.h" #include "loggingmanager.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace Core { namespace Internal { class LoggingCategoryItem { public: QString name; LoggingCategoryEntry entry; static LoggingCategoryItem fromJson(const QJsonObject &object, bool *ok); }; LoggingCategoryItem LoggingCategoryItem::fromJson(const QJsonObject &object, bool *ok) { if (!object.contains("name")) { *ok = false; return {}; } const QJsonValue entryVal = object.value("entry"); if (entryVal.isUndefined()) { *ok = false; return {}; } const QJsonObject entryObj = entryVal.toObject(); if (!entryObj.contains("level")) { *ok = false; return {}; } LoggingCategoryEntry entry; entry.level = QtMsgType(entryObj.value("level").toInt()); entry.enabled = true; if (entryObj.contains("color")) entry.color = QColor(entryObj.value("color").toString()); LoggingCategoryItem item {object.value("name").toString(), entry}; *ok = true; return item; } class LoggingCategoryModel : public QAbstractListModel { Q_OBJECT public: LoggingCategoryModel() = default; ~LoggingCategoryModel() override; bool append(const QString &category, const LoggingCategoryEntry &entry = {}); bool update(const QString &category, const LoggingCategoryEntry &entry); int columnCount(const QModelIndex &) const final { return 3; } int rowCount(const QModelIndex & = QModelIndex()) const final { return m_categories.count(); } QVariant data(const QModelIndex &index, int role) const final; bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) final; Qt::ItemFlags flags(const QModelIndex &index) const final; QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const final; void reset(); void setFromManager(LoggingViewManager *manager); QList enabledCategories() const; void disableAll(); signals: void categoryChanged(const QString &category, bool enabled); void colorChanged(const QString &category, const QColor &color); void logLevelChanged(const QString &category, QtMsgType logLevel); private: QList m_categories; }; LoggingCategoryModel::~LoggingCategoryModel() { reset(); } bool LoggingCategoryModel::append(const QString &category, const LoggingCategoryEntry &entry) { // no check? beginInsertRows(QModelIndex(), m_categories.size(), m_categories.size()); m_categories.append(new LoggingCategoryItem{category, entry}); endInsertRows(); return true; } bool LoggingCategoryModel::update(const QString &category, const LoggingCategoryEntry &entry) { if (m_categories.size() == 0) // should not happen return false; int row = 0; for (int end = m_categories.size(); row < end; ++row) { if (m_categories.at(row)->name == category) break; } if (row == m_categories.size()) // should not happen return false; setData(index(row, 0), Qt::Checked, Qt::CheckStateRole); setData(index(row, 1), LoggingViewManager::messageTypeToString(entry.level), Qt::EditRole); setData(index(row, 2), entry.color, Qt::DecorationRole); return true; } QVariant LoggingCategoryModel::data(const QModelIndex &index, int role) const { static const QColor defaultColor = Utils::creatorTheme()->palette().text().color(); if (!index.isValid()) return {}; if (role == Qt::DisplayRole) { if (index.column() == 0) return m_categories.at(index.row())->name; if (index.column() == 1) { return LoggingViewManager::messageTypeToString( m_categories.at(index.row())->entry.level); } } if (role == Qt::DecorationRole && index.column() == 2) { const QColor color = m_categories.at(index.row())->entry.color; if (color.isValid()) return color; return defaultColor; } if (role == Qt::CheckStateRole && index.column() == 0) { const LoggingCategoryEntry entry = m_categories.at(index.row())->entry; return entry.enabled ? Qt::Checked : Qt::Unchecked; } return {}; } bool LoggingCategoryModel::setData(const QModelIndex &index, const QVariant &value, int role) { if (!index.isValid()) return false; if (role == Qt::CheckStateRole && index.column() == 0) { LoggingCategoryItem *item = m_categories.at(index.row()); const Qt::CheckState current = item->entry.enabled ? Qt::Checked : Qt::Unchecked; if (current != value.toInt()) { item->entry.enabled = !item->entry.enabled; emit categoryChanged(item->name, item->entry.enabled); return true; } } else if (role == Qt::DecorationRole && index.column() == 2) { LoggingCategoryItem *item = m_categories.at(index.row()); QColor color = value.value(); if (color.isValid() && color != item->entry.color) { item->entry.color = color; emit colorChanged(item->name, color); return true; } } else if (role == Qt::EditRole && index.column() == 1) { LoggingCategoryItem *item = m_categories.at(index.row()); item->entry.level = LoggingViewManager::messageTypeFromString(value.toString()); emit logLevelChanged(item->name, item->entry.level); return true; } return false; } Qt::ItemFlags LoggingCategoryModel::flags(const QModelIndex &index) const { if (!index.isValid()) return Qt::NoItemFlags; // ItemIsEnabled should depend on availability (Qt logging enabled?) if (index.column() == 0) return Qt::ItemIsUserCheckable | Qt::ItemIsEnabled | Qt::ItemIsSelectable; if (index.column() == 1) return Qt::ItemIsEditable | Qt::ItemIsEnabled | Qt::ItemIsSelectable; return Qt::ItemIsEnabled | Qt::ItemIsSelectable; } QVariant LoggingCategoryModel::headerData(int section, Qt::Orientation orientation, int role) const { if (role == Qt::DisplayRole && orientation == Qt::Horizontal && section >= 0 && section < 3) { switch (section) { case 0: return Tr::tr("Category"); case 1: return Tr::tr("Type"); case 2: return Tr::tr("Color"); } } return {}; } void LoggingCategoryModel::reset() { beginResetModel(); qDeleteAll(m_categories); m_categories.clear(); endResetModel(); } void LoggingCategoryModel::setFromManager(LoggingViewManager *manager) { beginResetModel(); qDeleteAll(m_categories); m_categories.clear(); const QMap categories = manager->categories(); auto it = categories.begin(); for (auto end = categories.end() ; it != end; ++it) m_categories.append(new LoggingCategoryItem{it.key(), it.value()}); endResetModel(); } QList LoggingCategoryModel::enabledCategories() const { QList result; for (auto item : m_categories) { if (item->entry.enabled) result.append({item->name, item->entry}); } return result; } void LoggingCategoryModel::disableAll() { for (int row = 0, end = m_categories.count(); row < end; ++row) setData(index(row, 0), Qt::Unchecked, Qt::CheckStateRole); } class LoggingLevelDelegate : public QStyledItemDelegate { public: explicit LoggingLevelDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) {} ~LoggingLevelDelegate() = default; protected: QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; void setEditorData(QWidget *editor, const QModelIndex &index) const override; void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; }; QWidget *LoggingLevelDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &/*option*/, const QModelIndex &index) const { if (!index.isValid() || index.column() != 1) return nullptr; QComboBox *combo = new QComboBox(parent); combo->addItems({ {"Critical"}, {"Warning"}, {"Debug"}, {"Info"} }); return combo; } void LoggingLevelDelegate::setEditorData(QWidget *editor, const QModelIndex &index) const { QComboBox *combo = qobject_cast(editor); if (!combo) return; const int i = combo->findText(index.data().toString()); if (i >= 0) combo->setCurrentIndex(i); } void LoggingLevelDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const { QComboBox *combo = qobject_cast(editor); if (combo) model->setData(index, combo->currentText()); } class LogEntry { public: QString timestamp; QString category; QString type; QString message; QString outputLine(bool printTimestamp, bool printType) const { QString line; if (printTimestamp) line.append(timestamp + ' '); line.append(category); if (printType) line.append('.' + type.toLower()); line.append(": "); line.append(message); line.append('\n'); return line; } }; class LoggingViewManagerWidget : public QDialog { public: explicit LoggingViewManagerWidget(QWidget *parent); ~LoggingViewManagerWidget() { setEnabled(false); delete m_manager; } static QColor colorForCategory(const QString &category); private: void showLogViewContextMenu(const QPoint &pos) const; void showLogCategoryContextMenu(const QPoint &pos) const; void saveLoggingsToFile() const; void saveEnabledCategoryPreset() const; void loadAndUpdateFromPreset(); LoggingViewManager *m_manager = nullptr; void setCategoryColor(const QString &category, const QColor &color); // should category model be owned directly by the manager? or is this duplication of // categories in manager and widget beneficial? LoggingCategoryModel *m_categoryModel = nullptr; Utils::BaseTreeView *m_logView = nullptr; Utils::BaseTreeView *m_categoryView = nullptr; Utils::ListModel *m_logModel = nullptr; QToolButton *m_timestamps = nullptr; QToolButton *m_messageTypes = nullptr; static QHash m_categoryColor; }; QHash LoggingViewManagerWidget::m_categoryColor; static QVariant logEntryDataAccessor(const LogEntry &entry, int column, int role) { if (column >= 0 && column <= 3 && (role == Qt::DisplayRole || role == Qt::ToolTipRole)) { switch (column) { case 0: return entry.timestamp; case 1: return entry.category; case 2: return entry.type; case 3: { if (role == Qt::ToolTipRole) return entry.message; return entry.message.left(1000); } } } if (role == Qt::TextAlignmentRole) return Qt::AlignTop; if (column == 1 && role == Qt::ForegroundRole) return LoggingViewManagerWidget::colorForCategory(entry.category); return {}; } LoggingViewManagerWidget::LoggingViewManagerWidget(QWidget *parent) : QDialog(parent) , m_manager(new LoggingViewManager) { setWindowTitle(Tr::tr("Logging Category Viewer")); setModal(false); auto mainLayout = new QVBoxLayout; auto buttonsLayout = new QHBoxLayout; buttonsLayout->setSpacing(0); // add further buttons.. auto save = new QToolButton; save->setIcon(Utils::Icons::SAVEFILE.icon()); save->setToolTip(Tr::tr("Save Log")); buttonsLayout->addWidget(save); auto clean = new QToolButton; clean->setIcon(Utils::Icons::CLEAN.icon()); clean->setToolTip(Tr::tr("Clear")); buttonsLayout->addWidget(clean); auto stop = new QToolButton; stop->setIcon(Utils::Icons::STOP_SMALL.icon()); stop->setToolTip(Tr::tr("Stop Logging")); buttonsLayout->addWidget(stop); auto qtInternal = new QToolButton; qtInternal->setIcon(Core::Icons::QTLOGO.icon()); qtInternal->setToolTip(Tr::tr("Toggle Qt Internal Logging")); qtInternal->setCheckable(true); qtInternal->setChecked(false); buttonsLayout->addWidget(qtInternal); auto autoScroll = new QToolButton; autoScroll->setIcon(Utils::Icons::ARROW_DOWN.icon()); autoScroll->setToolTip(Tr::tr("Auto Scroll")); autoScroll->setCheckable(true); autoScroll->setChecked(true); buttonsLayout->addWidget(autoScroll); m_timestamps = new QToolButton; auto icon = Utils::Icon({{":/utils/images/stopwatch.png", Utils::Theme::PanelTextColorMid}}, Utils::Icon::Tint); m_timestamps->setIcon(icon.icon()); m_timestamps->setToolTip(Tr::tr("Timestamps")); m_timestamps->setCheckable(true); m_timestamps->setChecked(true); buttonsLayout->addWidget(m_timestamps); m_messageTypes = new QToolButton; icon = Utils::Icon({{":/utils/images/message.png", Utils::Theme::PanelTextColorMid}}, Utils::Icon::Tint); m_messageTypes->setIcon(icon.icon()); m_messageTypes->setToolTip(Tr::tr("Message Types")); m_messageTypes->setCheckable(true); m_messageTypes->setChecked(false); buttonsLayout->addWidget(m_messageTypes); buttonsLayout->addSpacerItem(new QSpacerItem(10, 10, QSizePolicy::Expanding)); mainLayout->addLayout(buttonsLayout); auto horizontal = new QHBoxLayout; m_logView = new Utils::BaseTreeView; m_logModel = new Utils::ListModel; m_logModel->setHeader({Tr::tr("Timestamp"), Tr::tr("Category"), Tr::tr("Type"), Tr::tr("Message")}); m_logModel->setDataAccessor(&logEntryDataAccessor); m_logView->setModel(m_logModel); horizontal->addWidget(m_logView); m_logView->setUniformRowHeights(true); m_logView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); m_logView->setFrameStyle(QFrame::Box); m_logView->setAttribute(Qt::WA_MacShowFocusRect, false); m_logView->setSelectionMode(QAbstractItemView::ExtendedSelection); m_logView->setColumnHidden(2, true); m_logView->setContextMenuPolicy(Qt::CustomContextMenu); m_categoryView = new Utils::BaseTreeView; m_categoryView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); m_categoryView->setUniformRowHeights(true); m_categoryView->setFrameStyle(QFrame::Box); m_categoryView->setAttribute(Qt::WA_MacShowFocusRect, false); m_categoryView->setSelectionMode(QAbstractItemView::SingleSelection); m_categoryView->setContextMenuPolicy(Qt::CustomContextMenu); m_categoryModel = new LoggingCategoryModel; m_categoryModel->setFromManager(m_manager); auto sortFilterModel = new QSortFilterProxyModel(this); sortFilterModel->setSourceModel(m_categoryModel); sortFilterModel->sort(0); m_categoryView->setModel(sortFilterModel); m_categoryView->setItemDelegateForColumn(1, new LoggingLevelDelegate(this)); horizontal->addWidget(m_categoryView); horizontal->setStretch(0, 5); horizontal->setStretch(1, 3); mainLayout->addLayout(horizontal); setLayout(mainLayout); resize(800, 300); connect(m_manager, &LoggingViewManager::receivedLog, this, [this](const QString ×tamp, const QString &type, const QString &category, const QString &msg) { if (m_logModel->rowCount() >= 1000000) // limit log to 1000000 items m_logModel->destroyItem(m_logModel->itemForIndex(m_logModel->index(0, 0))); m_logModel->appendItem(LogEntry{timestamp, type, category, msg}); }, Qt::QueuedConnection); connect(m_logModel, &QAbstractItemModel::rowsInserted, this, [this, autoScroll] { if (autoScroll->isChecked()) m_logView->scrollToBottom(); }, Qt::QueuedConnection); connect(m_manager, &LoggingViewManager::foundNewCategory, m_categoryModel, &LoggingCategoryModel::append, Qt::QueuedConnection); connect(m_manager, &LoggingViewManager::updatedCategory, m_categoryModel, &LoggingCategoryModel::update, Qt::QueuedConnection); connect(m_categoryModel, &LoggingCategoryModel::categoryChanged, m_manager, &LoggingViewManager::setCategoryEnabled); connect(m_categoryModel, &LoggingCategoryModel::colorChanged, this, &LoggingViewManagerWidget::setCategoryColor); connect(m_categoryModel, &LoggingCategoryModel::logLevelChanged, m_manager, &LoggingViewManager::setLogLevel); connect(m_categoryView, &Utils::BaseTreeView::activated, this, [this, sortFilterModel](const QModelIndex &index) { const QModelIndex modelIndex = sortFilterModel->mapToSource(index); const QVariant value = m_categoryModel->data(modelIndex, Qt::DecorationRole); if (!value.isValid()) return; const QColor original = value.value(); if (!original.isValid()) return; QColor changed = QColorDialog::getColor(original, this); if (!changed.isValid()) return; if (original != changed) m_categoryModel->setData(modelIndex, changed, Qt::DecorationRole); }); connect(save, &QToolButton::clicked, this, &LoggingViewManagerWidget::saveLoggingsToFile); connect(m_logView, &Utils::BaseTreeView::customContextMenuRequested, this, &LoggingViewManagerWidget::showLogViewContextMenu); connect(m_categoryView, &Utils::BaseTreeView::customContextMenuRequested, this, &LoggingViewManagerWidget::showLogCategoryContextMenu); connect(clean, &QToolButton::clicked, m_logModel, &Utils::ListModel::clear); connect(stop, &QToolButton::clicked, this, [this, stop] { if (m_manager->isEnabled()) { m_manager->setEnabled(false); stop->setIcon(Utils::Icons::RUN_SMALL.icon()); stop->setToolTip(Tr::tr("Start Logging")); } else { m_manager->setEnabled(true); stop->setIcon(Utils::Icons::STOP_SMALL.icon()); stop->setToolTip(Tr::tr("Stop Logging")); } }); connect(qtInternal, &QToolButton::toggled, m_manager, &LoggingViewManager::setListQtInternal); connect(m_timestamps, &QToolButton::toggled, this, [this](bool checked){ m_logView->setColumnHidden(0, !checked); }); connect(m_messageTypes, &QToolButton::toggled, this, [this](bool checked){ m_logView->setColumnHidden(2, !checked); }); } void LoggingViewManagerWidget::showLogViewContextMenu(const QPoint &pos) const { QMenu m; auto copy = new QAction(Tr::tr("Copy Selected Logs"), &m); m.addAction(copy); auto copyAll = new QAction(Tr::tr("Copy All"), &m); m.addAction(copyAll); connect(copy, &QAction::triggered, &m, [this] { auto selectionModel = m_logView->selectionModel(); QString copied; const bool useTS = m_timestamps->isChecked(); const bool useLL = m_messageTypes->isChecked(); for (int row = 0, end = m_logModel->rowCount(); row < end; ++row) { if (selectionModel->isRowSelected(row, QModelIndex())) copied.append(m_logModel->dataAt(row).outputLine(useTS, useLL)); } QGuiApplication::clipboard()->setText(copied); }); connect(copyAll, &QAction::triggered, &m, [this] { QString copied; const bool useTS = m_timestamps->isChecked(); const bool useLL = m_messageTypes->isChecked(); for (int row = 0, end = m_logModel->rowCount(); row < end; ++row) copied.append(m_logModel->dataAt(row).outputLine(useTS, useLL)); QGuiApplication::clipboard()->setText(copied); }); m.exec(m_logView->mapToGlobal(pos)); } void LoggingViewManagerWidget::showLogCategoryContextMenu(const QPoint &pos) const { QMenu m; // minimal load/save - plugins could later provide presets on their own? auto savePreset = new QAction(Tr::tr("Save Enabled as Preset..."), &m); m.addAction(savePreset); auto loadPreset = new QAction(Tr::tr("Update from Preset..."), &m); m.addAction(loadPreset); auto uncheckAll = new QAction(Tr::tr("Uncheck All"), &m); m.addAction(uncheckAll); connect(savePreset, &QAction::triggered, this, &LoggingViewManagerWidget::saveEnabledCategoryPreset); connect(loadPreset, &QAction::triggered, this, &LoggingViewManagerWidget::loadAndUpdateFromPreset); connect(uncheckAll, &QAction::triggered, m_categoryModel, &LoggingCategoryModel::disableAll); m.exec(m_categoryView->mapToGlobal(pos)); } void LoggingViewManagerWidget::saveLoggingsToFile() const { // should we just let it continue without temporarily disabling? const bool enabled = m_manager->isEnabled(); const QScopeGuard cleanup([this, enabled] { m_manager->setEnabled(enabled); }); if (enabled) m_manager->setEnabled(false); const Utils::FilePath fp = Utils::FileUtils::getSaveFilePath(ICore::dialogParent(), Tr::tr("Save Logs As")); if (fp.isEmpty()) return; const bool useTS = m_timestamps->isChecked(); const bool useLL = m_messageTypes->isChecked(); QFile file(fp.path()); if (file.open(QIODevice::WriteOnly)) { for (int row = 0, end = m_logModel->rowCount(); row < end; ++row) { qint64 res = file.write( m_logModel->dataAt(row).outputLine(useTS, useLL).toUtf8()); if (res == -1) { QMessageBox::critical( ICore::dialogParent(), Tr::tr("Error"), Tr::tr("Failed to write logs to \"%1\".").arg(fp.toUserOutput())); break; } } file.close(); } else { QMessageBox::critical( ICore::dialogParent(), Tr::tr("Error"), Tr::tr("Failed to open file \"%1\" for writing logs.").arg(fp.toUserOutput())); } } void LoggingViewManagerWidget::saveEnabledCategoryPreset() const { Utils::FilePath fp = Utils::FileUtils::getSaveFilePath(ICore::dialogParent(), Tr::tr("Save Enabled Categories As...")); if (fp.isEmpty()) return; const QList enabled = m_categoryModel->enabledCategories(); // write them to file QJsonArray array; for (const LoggingCategoryItem &item : enabled) { QJsonObject itemObj; itemObj.insert("name", item.name); QJsonObject entryObj; entryObj.insert("level", item.entry.level); if (item.entry.color.isValid()) entryObj.insert("color", item.entry.color.name(QColor::HexArgb)); itemObj.insert("entry", entryObj); array.append(itemObj); } QJsonDocument doc(array); if (!fp.writeFileContents(doc.toJson(QJsonDocument::Compact))) QMessageBox::critical( ICore::dialogParent(), Tr::tr("Error"), Tr::tr("Failed to write preset file \"%1\".").arg(fp.toUserOutput())); } void LoggingViewManagerWidget::loadAndUpdateFromPreset() { Utils::FilePath fp = Utils::FileUtils::getOpenFilePath(ICore::dialogParent(), Tr::tr("Load Enabled Categories From")); if (fp.isEmpty()) return; // read file, update categories const Utils::expected_str contents = fp.fileContents(); if (!contents) { QMessageBox::critical(ICore::dialogParent(), Tr::tr("Error"), Tr::tr("Failed to open preset file \"%1\" for reading.") .arg(fp.toUserOutput())); return; } QJsonParseError error; QJsonDocument doc = QJsonDocument::fromJson(*contents, &error); if (error.error != QJsonParseError::NoError) { QMessageBox::critical(ICore::dialogParent(), Tr::tr("Error"), Tr::tr("Failed to read preset file \"%1\": %2").arg(fp.toUserOutput()) .arg(error.errorString())); return; } bool formatError = false; QList presetItems; if (doc.isArray()) { const QJsonArray array = doc.array(); for (const QJsonValue &value : array) { if (!value.isObject()) { formatError = true; break; } const QJsonObject itemObj = value.toObject(); bool ok = true; LoggingCategoryItem item = LoggingCategoryItem::fromJson(itemObj, &ok); if (!ok) { formatError = true; break; } presetItems.append(item); } } else { formatError = true; } if (formatError) { QMessageBox::critical(ICore::dialogParent(), Tr::tr("Error"), Tr::tr("Unexpected preset file format.")); } for (const LoggingCategoryItem &item : presetItems) m_manager->appendOrUpdate(item.name, item.entry); } QColor LoggingViewManagerWidget::colorForCategory(const QString &category) { auto entry = m_categoryColor.find(category); if (entry == m_categoryColor.end()) return Utils::creatorTheme()->palette().text().color(); return entry.value(); } void LoggingViewManagerWidget::setCategoryColor(const QString &category, const QColor &color) { const QColor baseColor = Utils::creatorTheme()->palette().text().color(); if (color != baseColor) m_categoryColor.insert(category, color); else m_categoryColor.remove(category); } void LoggingViewer::showLoggingView() { ActionManager::command(Constants::LOGGER)->action()->setEnabled(false); auto widget = new LoggingViewManagerWidget(ICore::dialogParent()); QObject::connect(widget, &QDialog::finished, widget, [widget] { ActionManager::command(Constants::LOGGER)->action()->setEnabled(true); // explicitly disable manager again widget->deleteLater(); }); ICore::registerWindow(widget, Context("Qtc.LogViewer")); widget->show(); } } // namespace Internal } // namespace Core #include "loggingviewer.moc"