File indexing completed on 2024-04-28 05:49:32

0001 /*
0002     SPDX-FileCopyrightText: 2021 Christoph Cullmann <cullmann@kde.org>
0003 
0004     SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "kateoutputview.h"
0008 #include "kateapp.h"
0009 #include "katemainwindow.h"
0010 
0011 #include <KColorScheme>
0012 #include <KConfigGroup>
0013 #include <KLocalizedString>
0014 #include <KSharedConfig>
0015 #include <KTextEditor/Editor>
0016 
0017 #include <QApplication>
0018 #include <QClipboard>
0019 #include <QDateTime>
0020 #include <QMenu>
0021 #include <QPainter>
0022 #include <QScrollBar>
0023 #include <QTextBlock>
0024 #include <QTimeLine>
0025 #include <QToolButton>
0026 #include <QVBoxLayout>
0027 #include <QWindow>
0028 
0029 #include <ktexteditor_utils.h>
0030 
0031 #define GENERIC_ICON_NAME QLatin1String("dialog-scripts")
0032 
0033 class BlockData : public QTextBlockUserData
0034 {
0035 public:
0036     explicit BlockData(const QString &t)
0037         : token(t)
0038     {
0039     }
0040     const QString token;
0041 };
0042 
0043 class NewMsgIndicator : public QWidget
0044 {
0045     Q_OBJECT
0046 public:
0047     explicit NewMsgIndicator(QWidget *parent)
0048         : QWidget(parent)
0049         , m_timeline(1000, this)
0050     {
0051         setAttribute(Qt::WA_TransparentForMouseEvents, true);
0052         setGeometry(parent->geometry().adjusted(-2, -2, 2, 2));
0053 
0054         m_timeline.setDirection(QTimeLine::Forward);
0055         m_timeline.setEasingCurve(QEasingCurve::SineCurve);
0056         m_timeline.setFrameRange(20, 150);
0057         auto update = QOverload<>::of(&QWidget::update);
0058         connect(&m_timeline, &QTimeLine::valueChanged, this, update);
0059         connect(&m_timeline, &QTimeLine::finished, this, &QObject::deleteLater);
0060         connect(window()->windowHandle(), &QWindow::activeChanged, this, [this]() {
0061             if (window()->windowHandle()->isActive() && m_timeline.state() == QTimeLine::Paused) {
0062                 m_timeline.setPaused(false);
0063             } else if (!window()->windowHandle()->isActive()) {
0064                 m_timeline.setPaused(true);
0065             }
0066         });
0067     }
0068 
0069     void run(int loopCount, QColor c)
0070     {
0071         // If parent is not visible, do nothing
0072         if (auto p = qobject_cast<QWidget *>(parent()); p && !p->isVisible()) {
0073             return;
0074         }
0075         m_flashColor = c;
0076         show();
0077         raise();
0078         m_timeline.setLoopCount(loopCount);
0079         m_timeline.start();
0080         if (!isVisible() || !window()->windowHandle()->isActive()) {
0081             m_timeline.setPaused(true);
0082         }
0083     }
0084 
0085     Q_SLOT void stop()
0086     {
0087         m_timeline.stop();
0088         deleteLater();
0089     }
0090 
0091 protected:
0092     void paintEvent(QPaintEvent *) override
0093     {
0094         if (m_timeline.state() == QTimeLine::Running) {
0095             QPainter p(this);
0096             p.setRenderHint(QPainter::Antialiasing);
0097             m_flashColor.setAlpha(m_timeline.currentFrame());
0098             p.setBrush(m_flashColor);
0099             p.setPen(Qt::NoPen);
0100             p.drawRoundedRect(rect(), 15, 15);
0101         }
0102     }
0103 
0104     void hideEvent(QHideEvent *) override
0105     {
0106         m_timeline.setPaused(true);
0107     }
0108 
0109     void showEvent(QShowEvent *) override
0110     {
0111         if (m_timeline.state() == QTimeLine::Paused) {
0112             m_timeline.setPaused(false);
0113         }
0114     }
0115 
0116 private:
0117     QColor m_flashColor;
0118     QTimeLine m_timeline;
0119 };
0120 
0121 class KateOutputEdit : public QTextBrowser
0122 {
0123     Q_OBJECT
0124 public:
0125     explicit KateOutputEdit(QWidget *parent)
0126         : QTextBrowser(parent)
0127     {
0128         setOpenExternalLinks(true);
0129     }
0130 
0131     QVariant loadResource(int type, const QUrl &name) override
0132     {
0133         if (type == QTextDocument::ImageResource) {
0134             const QPixmap icon = m_iconCache[name.toString()];
0135             if (!icon.isNull()) {
0136                 return icon;
0137             }
0138         }
0139         return QTextBrowser::loadResource(type, name);
0140     }
0141 
0142     void addIcon(const QString &cat, const QIcon &icon)
0143     {
0144         const auto it = m_iconCache.constFind(cat);
0145         if (it == m_iconCache.constEnd()) {
0146             if (cat == GENERIC_ICON_NAME) {
0147                 m_iconCache[GENERIC_ICON_NAME] = QIcon::fromTheme(GENERIC_ICON_NAME).pixmap(16, 16);
0148             } else {
0149                 m_iconCache[cat] = icon.pixmap(16, 16);
0150             }
0151         }
0152     }
0153 
0154 private:
0155     QHash<QString, QPixmap> m_iconCache;
0156 };
0157 
0158 KateOutputView::KateOutputView(KateMainWindow *mainWindow, QWidget *parent)
0159     : QWidget(parent)
0160     , m_mainWindow(mainWindow)
0161     , m_textEdit(new KateOutputEdit(this))
0162 {
0163     setFocusPolicy(Qt::NoFocus);
0164 
0165     QVBoxLayout *layout = new QVBoxLayout(this);
0166     layout->setContentsMargins(0, 0, 0, 0);
0167     layout->setSpacing(0);
0168 
0169     m_searchTimer.setInterval(400);
0170     m_searchTimer.setSingleShot(true);
0171     m_searchTimer.callOnTimeout(this, &KateOutputView::search);
0172 
0173     // filter line edit
0174     m_filterLine.setPlaceholderText(i18n("Search..."));
0175     m_filterLine.setClearButtonEnabled(true);
0176     m_filterLine.setProperty("_breeze_borders_sides", QVariant::fromValue(QFlags{Qt::RightEdge}));
0177     connect(&m_filterLine, &QLineEdit::textChanged, this, [this]() {
0178         m_searchTimer.start();
0179     });
0180 
0181     // copy button
0182     auto copy = new QToolButton(this);
0183     connect(copy, &QToolButton::clicked, this, [this] {
0184         const QString text = m_textEdit->toPlainText();
0185         if (!text.isEmpty()) {
0186             qApp->clipboard()->setText(text);
0187         }
0188     });
0189     copy->setIcon(QIcon::fromTheme(QStringLiteral("edit-copy")));
0190     copy->setToolButtonStyle(Qt::ToolButtonIconOnly);
0191     copy->setToolTip(i18n("Copy all text to clipboard"));
0192 
0193     // clear button
0194     auto clear = new QToolButton(this);
0195     clear->setIcon(QIcon::fromTheme(QStringLiteral("edit-clear-history")));
0196     clear->setToolTip(i18n("Clear all messages"));
0197     connect(clear, &QPushButton::clicked, this, [this] {
0198         m_textEdit->clear();
0199     });
0200 
0201     // setup top horizontal layout
0202     // tried toolbar, has bad spacing
0203     QHBoxLayout *hLayout = new QHBoxLayout();
0204     hLayout->setSpacing(3);
0205     hLayout->addWidget(&m_filterLine);
0206     hLayout->addWidget(copy);
0207     hLayout->addWidget(clear);
0208     hLayout->setStretch(0, 1);
0209 
0210     // Vertical separator
0211     auto separator = new QFrame(this);
0212     separator->setFrameShape(QFrame::HLine);
0213     separator->setEnabled(false);
0214     separator->setFixedHeight(1);
0215 
0216     // tree view
0217     layout->addLayout(hLayout);
0218     layout->addWidget(separator);
0219     layout->addWidget(m_textEdit);
0220 
0221     // handle tab button creation
0222     connect(mainWindow, &KateMainWindow::tabForToolViewAdded, this, &KateOutputView::tabForToolViewAdded);
0223 
0224     // handle config changes & apply initial configuration
0225     connect(KateApp::self(), &KateApp::configurationChanged, this, &KateOutputView::readConfig);
0226     connect(KTextEditor::Editor::instance(), &KTextEditor::Editor::configChanged, this, &KateOutputView::readConfig);
0227     readConfig();
0228 }
0229 
0230 void KateOutputView::tabForToolViewAdded(QWidget *toolView, QWidget *tab)
0231 {
0232     if (parent() == toolView) {
0233         tabButton = tab;
0234     }
0235 }
0236 
0237 void KateOutputView::search()
0238 {
0239     const QString text = m_filterLine.text();
0240     if (text.isEmpty()) {
0241         m_textEdit->setExtraSelections({});
0242         return;
0243     }
0244 
0245     const auto theme = KTextEditor::Editor::instance()->theme();
0246     QTextCharFormat f;
0247     f.setBackground(QColor::fromRgba(theme.editorColor(KSyntaxHighlighting::Theme::SearchHighlight)));
0248 
0249     QList<QTextEdit::ExtraSelection> sels;
0250     const auto *doc = m_textEdit->document();
0251     QTextCursor cursor = doc->find(text, 0);
0252     while (!cursor.isNull()) {
0253         QTextEdit::ExtraSelection s;
0254         s.cursor = cursor;
0255         s.format = f;
0256         sels.append(s);
0257         cursor = doc->find(text, cursor);
0258     }
0259 
0260     if (!sels.isEmpty()) {
0261         m_textEdit->setExtraSelections(sels);
0262         if (auto scroll = m_textEdit->verticalScrollBar()) {
0263             scroll->setValue(sels.constFirst().cursor.blockNumber());
0264         }
0265     }
0266 }
0267 
0268 void KateOutputView::readConfig()
0269 {
0270     KSharedConfig::Ptr config = KSharedConfig::openConfig();
0271     KConfigGroup cgGeneral = KConfigGroup(config, QStringLiteral("General"));
0272     m_showOutputViewForMessageType = cgGeneral.readEntry("Show output view for message type", 1);
0273     const int historyLimit = cgGeneral.readEntry("Output History Limit", 100);
0274 
0275     if (historyLimit != m_historyLimit) {
0276         m_historyLimit = historyLimit;
0277         m_textEdit->document()->setMaximumBlockCount(m_historyLimit);
0278     }
0279 
0280     // use editor fonts
0281     const auto theme = KTextEditor::Editor::instance()->theme();
0282     auto pal = m_textEdit->palette();
0283     pal.setColor(QPalette::Base, QColor::fromRgba(theme.editorColor(KSyntaxHighlighting::Theme::BackgroundColor)));
0284     pal.setColor(QPalette::Highlight, QColor::fromRgba(theme.editorColor(KSyntaxHighlighting::Theme::TextSelection)));
0285     pal.setColor(QPalette::Text, QColor::fromRgba(theme.textColor(KSyntaxHighlighting::Theme::Normal)));
0286     m_textEdit->setPalette(pal);
0287     m_textEdit->setFont(Utils::editorFont());
0288     m_textEdit->document()->setIndentWidth(m_textEdit->fontMetrics().horizontalAdvance(QLatin1Char(' ')));
0289 
0290     auto brighten = [](QColor &c) {
0291         c = c.toHsv();
0292         c.setHsv(c.hue(), qMin(c.saturation() + 35, 255), qMin(c.value() + 10, 255));
0293     };
0294 
0295     KColorScheme c;
0296     m_msgIndicatorColors[0] = c.background(KColorScheme::NegativeBackground).color();
0297     brighten(m_msgIndicatorColors[0]);
0298     m_msgIndicatorColors[1] = c.background(KColorScheme::NeutralBackground).color();
0299     brighten(m_msgIndicatorColors[1]);
0300     m_msgIndicatorColors[2] = c.background(KColorScheme::PositiveBackground).color();
0301     brighten(m_msgIndicatorColors[2]);
0302 
0303     m_infoColor = QColor::fromRgba(theme.textColor(KSyntaxHighlighting::Theme::Information)).name();
0304     m_warnColor = QColor::fromRgba(theme.textColor(KSyntaxHighlighting::Theme::Warning)).name();
0305     m_errColor = QColor::fromRgba(theme.textColor(KSyntaxHighlighting::Theme::Error)).name();
0306     m_keywordColor = QColor::fromRgba(theme.textColor(KSyntaxHighlighting::Theme::DataType)).name();
0307 }
0308 
0309 static void wrapLinksWithHref(QString &text)
0310 {
0311     static const QRegularExpression re(
0312         QStringLiteral(R"re((https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)))re"));
0313     text.replace(re, QStringLiteral("<a href=\"\\1\" >\\1</a>"));
0314 }
0315 
0316 void KateOutputView::appendLines(const QStringList &lines, const QString &token, const QTextCursor &pos)
0317 {
0318     QTextCursor cursor = pos;
0319     if (cursor.isNull()) {
0320         cursor = m_textEdit->textCursor();
0321         if (!cursor.atEnd()) {
0322             cursor.movePosition(QTextCursor::End);
0323             m_textEdit->setTextCursor(cursor);
0324         }
0325     }
0326     bool atStart = cursor.atStart();
0327 
0328     int i = 0;
0329     for (const auto &l : lines) {
0330         if (!atStart) {
0331             cursor.insertBlock();
0332             cursor.setBlockFormat({});
0333         }
0334         cursor.insertHtml(QLatin1String("<span style=\"white-space:pre\">") + l + QLatin1String("</span>"));
0335         if (i >= 1) {
0336             QTextBlockFormat fmt;
0337             fmt.setIndent(8);
0338             cursor.setBlockFormat(fmt);
0339         }
0340         atStart = false;
0341         i++;
0342     }
0343 
0344     if (!token.isEmpty()) {
0345         cursor.block().setUserData(new BlockData(token));
0346     }
0347 }
0348 
0349 void KateOutputView::slotMessage(const QVariantMap &message)
0350 {
0351     /**
0352      * discard all messages without any real text
0353      */
0354     auto text = message.value(QStringLiteral("text")).toString().trimmed() /*.replace(QStringLiteral("\n"), QStringLiteral("<br>"))*/;
0355     if (text.isEmpty()) {
0356         return;
0357     }
0358 
0359     /*
0360      * subsequent message might replace a former one (e.g. for progress)
0361      */
0362     const auto token = message.value(QStringLiteral("token")).toString();
0363 
0364     QString meta = QStringLiteral("[");
0365 
0366     /**
0367      * date time column: we want to know when a message arrived
0368      * TODO: perhaps store full date time for more stuff later
0369      */
0370     const QDateTime current = QDateTime::currentDateTime();
0371     meta += current.time().toString(Qt::TextDate);
0372 
0373     /**
0374      * category
0375      * provided by sender to better categorize the output into stuff like: lsp, git, ...
0376      * optional icon support
0377      */
0378     const QString category = message.value(QStringLiteral("category")).toString().trimmed();
0379     const auto categoryIcon = message.value(QStringLiteral("categoryIcon")).value<QIcon>();
0380     if (categoryIcon.isNull()) {
0381         m_textEdit->addIcon(GENERIC_ICON_NAME, {});
0382         meta += QStringLiteral(" <img style=\"vertical-align:middle\" src=\"") + GENERIC_ICON_NAME + QStringLiteral("\"/> ");
0383     } else {
0384         m_textEdit->addIcon(category, categoryIcon);
0385         meta += QStringLiteral(" <img style=\"vertical-align:middle\" src=\"") + category + QStringLiteral("\"/> ");
0386     }
0387 
0388     meta += QStringLiteral("<span style=\"color:%1\">").arg(m_keywordColor) + category + QStringLiteral("</span> ");
0389 
0390     /**
0391      * type column: shows the type, icons for some types only
0392      */
0393     bool shouldShowOutputToolView = false;
0394     int indicatorLoopCount = 0; // for warning/error infinite loop
0395     QColor color;
0396     const auto typeString = message.value(QStringLiteral("type")).toString();
0397     if (typeString == QLatin1String("Error")) {
0398         shouldShowOutputToolView = (m_showOutputViewForMessageType >= 1);
0399         meta += QStringLiteral("<span style=\"color:%1\">").arg(m_errColor) + i18nc("@info", "Error") + QStringLiteral("</span>");
0400         color = m_msgIndicatorColors[0];
0401     } else if (typeString == QLatin1String("Warning")) {
0402         shouldShowOutputToolView = (m_showOutputViewForMessageType >= 2);
0403         meta += QStringLiteral("<span style=\"color:%1\">").arg(m_warnColor) + i18nc("@info", "Warning") + QStringLiteral("</span>");
0404         color = m_msgIndicatorColors[1];
0405     } else if (typeString == QLatin1String("Info")) {
0406         shouldShowOutputToolView = (m_showOutputViewForMessageType >= 3);
0407         meta += QStringLiteral("<span style=\"color:%1\">").arg(m_infoColor) + i18nc("@info", "Info") + QStringLiteral("</span>");
0408         indicatorLoopCount = 2;
0409         color = m_msgIndicatorColors[2];
0410     } else {
0411         shouldShowOutputToolView = (m_showOutputViewForMessageType >= 4);
0412         meta += i18nc("@info", "Log");
0413         indicatorLoopCount = -1; // no FadingIndicator for log messages
0414     }
0415 
0416     meta += QStringLiteral("] ");
0417 
0418     if (shouldShowOutputToolView || isVisible()) {
0419         // if we are going to show the output toolview afterwards
0420         indicatorLoopCount = 1;
0421     }
0422 
0423     if (!m_fadingIndicator && indicatorLoopCount >= 0 && tabButton) {
0424         m_fadingIndicator = new NewMsgIndicator(tabButton);
0425         m_fadingIndicator->run(indicatorLoopCount, color);
0426         connect(tabButton, SIGNAL(clicked()), m_fadingIndicator, SLOT(stop()));
0427     }
0428 
0429     /**
0430      * actual message text
0431      */
0432     wrapLinksWithHref(text);
0433     auto lines = text.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
0434     if (lines.isEmpty()) {
0435         return;
0436     }
0437 
0438     if (!token.isEmpty()) {
0439         const auto doc = m_textEdit->document();
0440         auto block = doc->lastBlock();
0441         bool found = false;
0442         while (block.isValid()) {
0443             auto data = static_cast<BlockData *>(block.userData());
0444             if (data && data->token == token) {
0445                 found = true;
0446                 break;
0447             }
0448             block = block.previous();
0449         }
0450 
0451         if (!found) {
0452             lines.first().prepend(meta);
0453             appendLines(lines, token);
0454         } else if (block.isValid()) {
0455             QTextCursor c(block);
0456 
0457             c.select(QTextCursor::BlockUnderCursor);
0458             c.removeSelectedText();
0459             if (lines.size() == 1) {
0460                 c.insertBlock();
0461                 c.insertHtml(meta + lines.first());
0462             } else {
0463                 lines.first().prepend(meta);
0464                 appendLines(lines, token, c);
0465             }
0466             c.block().setUserData(new BlockData(token));
0467         } else {
0468             qWarning() << Q_FUNC_INFO << "unable to find valid block!";
0469             m_textEdit->append(meta);
0470             auto data = new BlockData(token);
0471             m_textEdit->document()->lastBlock().setUserData(data);
0472         }
0473     } else {
0474         lines.first().prepend(meta);
0475         appendLines(lines, /*token=*/QString());
0476     }
0477 
0478     /**
0479      * ensure last item is visible
0480      */
0481     if (auto scroll = m_textEdit->verticalScrollBar()) {
0482         scroll->setValue(scroll->maximum());
0483     }
0484 
0485     /**
0486      * if message requires it => show the tool view if hidden
0487      */
0488     if (shouldShowOutputToolView) {
0489         QPointer<QWidget> focusWidget = qApp->focusWidget();
0490         m_mainWindow->showToolView(qobject_cast<QWidget *>(parent()));
0491         if (focusWidget) {
0492             focusWidget->setFocus();
0493         }
0494     }
0495 }
0496 
0497 #include "kateoutputview.moc"
0498 #include "moc_kateoutputview.cpp"