/**************************************************************************** ** ** Copyright (C) 2016 The Qt Company Ltd. ** Contact: https://www.qt.io/licensing/ ** ** This file is part of Qt Creator. ** ** Commercial License Usage ** Licensees holding valid commercial Qt licenses may use this file in ** accordance with the commercial license agreement provided with the ** Software or, alternatively, in accordance with the terms contained in ** a written agreement between you and The Qt Company. For licensing terms ** and conditions see https://www.qt.io/terms-conditions. For further ** information use the contact form at https://www.qt.io/contact-us. ** ** GNU General Public License Usage ** Alternatively, this file may be used under the terms of the GNU ** General Public License version 3 as published by the Free Software ** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT ** included in the packaging of this file. Please review the following ** information to ensure the GNU General Public License requirements will ** be met: https://www.gnu.org/licenses/gpl-3.0.html. ** ****************************************************************************/ #include "outputwindow.h" #include "actionmanager/actionmanager.h" #include "editormanager/editormanager.h" #include "coreconstants.h" #include "coreplugin.h" #include "icore.h" #include #include #include #include #include #include #include #include #include #include #include #ifdef WITH_TESTS #include #endif #include const int chunkSize = 10000; using namespace Utils; namespace Core { namespace Internal { class OutputWindowPrivate { public: explicit OutputWindowPrivate(QTextDocument *document) : cursor(document) { } QString settingsKey; OutputFormatter formatter; QList> queuedOutput; QTimer queueTimer; bool flushRequested = false; bool scrollToBottom = true; bool linksActive = true; bool zoomEnabled = false; float originalFontSize = 0.; bool originalReadOnly = false; int maxCharCount = Core::Constants::DEFAULT_MAX_CHAR_COUNT; Qt::MouseButton mouseButtonPressed = Qt::NoButton; QTextCursor cursor; QString filterText; int lastFilteredBlockNumber = -1; QPalette originalPalette; OutputWindow::FilterModeFlags filterMode = OutputWindow::FilterModeFlag::Default; QTimer scrollTimer; QElapsedTimer lastMessage; }; } // namespace Internal /*******************/ OutputWindow::OutputWindow(Context context, const QString &settingsKey, QWidget *parent) : QPlainTextEdit(parent) , d(new Internal::OutputWindowPrivate(document())) { setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); //setCenterOnScroll(false); setFrameShape(QFrame::NoFrame); setMouseTracking(true); setUndoRedoEnabled(false); d->formatter.setPlainTextEdit(this); d->queueTimer.setSingleShot(true); d->queueTimer.setInterval(10); connect(&d->queueTimer, &QTimer::timeout, this, &OutputWindow::handleNextOutputChunk); d->settingsKey = settingsKey; auto outputWindowContext = new IContext(this); outputWindowContext->setContext(context); outputWindowContext->setWidget(this); ICore::addContextObject(outputWindowContext); auto undoAction = new QAction(this); auto redoAction = new QAction(this); auto cutAction = new QAction(this); auto copyAction = new QAction(this); auto pasteAction = new QAction(this); auto selectAllAction = new QAction(this); ActionManager::registerAction(undoAction, Constants::UNDO, context); ActionManager::registerAction(redoAction, Constants::REDO, context); ActionManager::registerAction(cutAction, Constants::CUT, context); ActionManager::registerAction(copyAction, Constants::COPY, context); ActionManager::registerAction(pasteAction, Constants::PASTE, context); ActionManager::registerAction(selectAllAction, Constants::SELECTALL, context); connect(undoAction, &QAction::triggered, this, &QPlainTextEdit::undo); connect(redoAction, &QAction::triggered, this, &QPlainTextEdit::redo); connect(cutAction, &QAction::triggered, this, &QPlainTextEdit::cut); connect(copyAction, &QAction::triggered, this, &QPlainTextEdit::copy); connect(pasteAction, &QAction::triggered, this, &QPlainTextEdit::paste); connect(selectAllAction, &QAction::triggered, this, &QPlainTextEdit::selectAll); connect(this, &QPlainTextEdit::blockCountChanged, this, [this] { if (!d->filterText.isEmpty()) filterNewContent(); }); connect(this, &QPlainTextEdit::undoAvailable, undoAction, &QAction::setEnabled); connect(this, &QPlainTextEdit::redoAvailable, redoAction, &QAction::setEnabled); connect(this, &QPlainTextEdit::copyAvailable, cutAction, &QAction::setEnabled); // OutputWindow never read-only connect(this, &QPlainTextEdit::copyAvailable, copyAction, &QAction::setEnabled); connect(Core::ICore::instance(), &Core::ICore::saveSettingsRequested, this, [this] { if (!d->settingsKey.isEmpty()) Core::ICore::settings()->setValue(d->settingsKey, fontZoom()); }); connect(outputFormatter(), &OutputFormatter::openInEditorRequested, this, [](const Utils::FilePath &fp, int line, int column) { EditorManager::openEditorAt(fp.toString(), line, column); }); undoAction->setEnabled(false); redoAction->setEnabled(false); cutAction->setEnabled(false); copyAction->setEnabled(false); d->scrollTimer.setInterval(10); d->scrollTimer.setSingleShot(true); connect(&d->scrollTimer, &QTimer::timeout, this, &OutputWindow::scrollToBottom); d->lastMessage.start(); d->originalFontSize = font().pointSizeF(); if (!d->settingsKey.isEmpty()) { float zoom = Core::ICore::settings()->value(d->settingsKey).toFloat(); setFontZoom(zoom); } } OutputWindow::~OutputWindow() { delete d; } void OutputWindow::mousePressEvent(QMouseEvent *e) { d->mouseButtonPressed = e->button(); QPlainTextEdit::mousePressEvent(e); } void OutputWindow::handleLink(const QPoint &pos) { const QString href = anchorAt(pos); if (!href.isEmpty()) d->formatter.handleLink(href); } void OutputWindow::mouseReleaseEvent(QMouseEvent *e) { if (d->linksActive && d->mouseButtonPressed == Qt::LeftButton) handleLink(e->pos()); // Mouse was released, activate links again d->linksActive = true; d->mouseButtonPressed = Qt::NoButton; QPlainTextEdit::mouseReleaseEvent(e); } void OutputWindow::mouseMoveEvent(QMouseEvent *e) { // Cursor was dragged to make a selection, deactivate links if (d->mouseButtonPressed != Qt::NoButton && textCursor().hasSelection()) d->linksActive = false; if (!d->linksActive || anchorAt(e->pos()).isEmpty()) viewport()->setCursor(Qt::IBeamCursor); else viewport()->setCursor(Qt::PointingHandCursor); QPlainTextEdit::mouseMoveEvent(e); } void OutputWindow::resizeEvent(QResizeEvent *e) { //Keep scrollbar at bottom of window while resizing, to ensure we keep scrolling //This can happen if window is resized while building, or if the horizontal scrollbar appears bool atBottom = isScrollbarAtBottom(); QPlainTextEdit::resizeEvent(e); if (atBottom) scrollToBottom(); } void OutputWindow::keyPressEvent(QKeyEvent *ev) { QPlainTextEdit::keyPressEvent(ev); //Ensure we scroll also on Ctrl+Home or Ctrl+End if (ev->matches(QKeySequence::MoveToStartOfDocument)) verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); else if (ev->matches(QKeySequence::MoveToEndOfDocument)) verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMaximum); } void OutputWindow::setLineParsers(const QList &parsers) { reset(); d->formatter.setLineParsers(parsers); } OutputFormatter *OutputWindow::outputFormatter() const { return &d->formatter; } void OutputWindow::showEvent(QShowEvent *e) { QPlainTextEdit::showEvent(e); if (d->scrollToBottom) verticalScrollBar()->setValue(verticalScrollBar()->maximum()); d->scrollToBottom = false; } void OutputWindow::wheelEvent(QWheelEvent *e) { if (d->zoomEnabled) { if (e->modifiers() & Qt::ControlModifier) { float delta = e->angleDelta().y() / 120.f; // Workaround for QTCREATORBUG-22721, remove when properly fixed in Qt const float newSize = float(font().pointSizeF()) + delta; if (delta < 0.f && newSize < 4.f) return; zoomInF(delta); emit wheelZoom(); return; } } QAbstractScrollArea::wheelEvent(e); updateMicroFocus(); } void OutputWindow::setBaseFont(const QFont &newFont) { float zoom = fontZoom(); d->originalFontSize = newFont.pointSizeF(); QFont tmp = newFont; float newZoom = qMax(d->originalFontSize + zoom, 4.0f); tmp.setPointSizeF(newZoom); setFont(tmp); } float OutputWindow::fontZoom() const { return font().pointSizeF() - d->originalFontSize; } void OutputWindow::setFontZoom(float zoom) { QFont f = font(); if (f.pointSizeF() == d->originalFontSize + zoom) return; float newZoom = qMax(d->originalFontSize + zoom, 4.0f); f.setPointSizeF(newZoom); setFont(f); } void OutputWindow::setWheelZoomEnabled(bool enabled) { d->zoomEnabled = enabled; } void OutputWindow::updateFilterProperties( const QString &filterText, Qt::CaseSensitivity caseSensitivity, bool isRegexp, bool isInverted ) { FilterModeFlags flags; flags.setFlag(FilterModeFlag::CaseSensitive, caseSensitivity == Qt::CaseSensitive) .setFlag(FilterModeFlag::RegExp, isRegexp) .setFlag(FilterModeFlag::Inverted, isInverted); if (d->filterMode == flags && d->filterText == filterText) return; d->lastFilteredBlockNumber = -1; if (d->filterText != filterText) { const bool filterTextWasEmpty = d->filterText.isEmpty(); d->filterText = filterText; // Update textedit's background color if (filterText.isEmpty() && !filterTextWasEmpty) { setPalette(d->originalPalette); setReadOnly(d->originalReadOnly); } if (!filterText.isEmpty() && filterTextWasEmpty) { d->originalReadOnly = isReadOnly(); setReadOnly(true); const auto newBgColor = [this] { const QColor currentColor = palette().color(QPalette::Base); const int factor = 120; return currentColor.value() < 128 ? currentColor.lighter(factor) : currentColor.darker(factor); }; QPalette p = palette(); p.setColor(QPalette::Base, newBgColor()); setPalette(p); } } d->filterMode = flags; filterNewContent(); } void OutputWindow::filterNewContent() { bool atBottom = isScrollbarAtBottom(); QTextBlock lastBlock = document()->findBlockByNumber(d->lastFilteredBlockNumber); if (!lastBlock.isValid()) lastBlock = document()->begin(); const bool invert = d->filterMode.testFlag(FilterModeFlag::Inverted); if (d->filterMode.testFlag(OutputWindow::FilterModeFlag::RegExp)) { QRegularExpression regExp(d->filterText); if (!d->filterMode.testFlag(OutputWindow::FilterModeFlag::CaseSensitive)) regExp.setPatternOptions(QRegularExpression::CaseInsensitiveOption); for (; lastBlock != document()->end(); lastBlock = lastBlock.next()) lastBlock.setVisible(d->filterText.isEmpty() || regExp.match(lastBlock.text()).hasMatch() != invert); } else { if (d->filterMode.testFlag(OutputWindow::FilterModeFlag::CaseSensitive)) { for (; lastBlock != document()->end(); lastBlock = lastBlock.next()) lastBlock.setVisible(d->filterText.isEmpty() || lastBlock.text().contains(d->filterText) != invert); } else { for (; lastBlock != document()->end(); lastBlock = lastBlock.next()) { lastBlock.setVisible(d->filterText.isEmpty() || lastBlock.text().toLower() .contains(d->filterText.toLower()) != invert); } } } d->lastFilteredBlockNumber = document()->lastBlock().blockNumber(); // FIXME: Why on earth is this necessary? We should probably do something else instead... setDocument(document()); if (atBottom) scrollToBottom(); } void OutputWindow::handleNextOutputChunk() { QTC_ASSERT(!d->queuedOutput.isEmpty(), return); auto &chunk = d->queuedOutput.first(); if (chunk.first.size() <= chunkSize) { handleOutputChunk(chunk.first, chunk.second); d->queuedOutput.removeFirst(); } else { handleOutputChunk(chunk.first.left(chunkSize), chunk.second); chunk.first.remove(0, chunkSize); } if (!d->queuedOutput.isEmpty()) d->queueTimer.start(); else if (d->flushRequested) { d->formatter.flush(); d->flushRequested = false; } } void OutputWindow::handleOutputChunk(const QString &output, OutputFormat format) { QString out = output; if (out.size() > d->maxCharCount) { // Current chunk alone exceeds limit, we need to cut it. const int elided = out.size() - d->maxCharCount; out = out.left(d->maxCharCount / 2) + "[[[... " + tr("Elided %n characters due to Application Output settings", nullptr, elided) + " ...]]]" + out.right(d->maxCharCount / 2); setMaximumBlockCount(out.count('\n') + 1); } else { int plannedChars = document()->characterCount() + out.size(); if (plannedChars > d->maxCharCount) { int plannedBlockCount = document()->blockCount(); QTextBlock tb = document()->firstBlock(); while (tb.isValid() && plannedChars > d->maxCharCount && plannedBlockCount > 1) { plannedChars -= tb.length(); plannedBlockCount -= 1; tb = tb.next(); } setMaximumBlockCount(plannedBlockCount); } else { setMaximumBlockCount(-1); } } const bool atBottom = isScrollbarAtBottom() || d->scrollTimer.isActive(); d->scrollToBottom = true; d->formatter.appendMessage(out, format); if (atBottom) { if (d->lastMessage.elapsed() < 5) { d->scrollTimer.start(); } else { d->scrollTimer.stop(); scrollToBottom(); } } d->lastMessage.start(); enableUndoRedo(); } void OutputWindow::setMaxCharCount(int count) { d->maxCharCount = count; setMaximumBlockCount(count / 100); } int OutputWindow::maxCharCount() const { return d->maxCharCount; } void OutputWindow::appendMessage(const QString &output, OutputFormat format) { if (d->queuedOutput.isEmpty() || d->queuedOutput.last().second != format) d->queuedOutput << qMakePair(output, format); else d->queuedOutput.last().first.append(output); if (!d->queueTimer.isActive()) d->queueTimer.start(); } bool OutputWindow::isScrollbarAtBottom() const { return verticalScrollBar()->value() == verticalScrollBar()->maximum(); } QMimeData *OutputWindow::createMimeDataFromSelection() const { const auto mimeData = new QMimeData; QString content; const int selStart = textCursor().selectionStart(); const int selEnd = textCursor().selectionEnd(); const QTextBlock firstBlock = document()->findBlock(selStart); const QTextBlock lastBlock = document()->findBlock(selEnd); for (QTextBlock curBlock = firstBlock; curBlock != lastBlock; curBlock = curBlock.next()) { if (!curBlock.isVisible()) continue; if (curBlock == firstBlock) content += curBlock.text().mid(selStart - firstBlock.position()); else content += curBlock.text(); content += '\n'; } if (lastBlock.isValid() && lastBlock.isVisible()) { if (firstBlock == lastBlock) content = textCursor().selectedText(); else content += lastBlock.text().mid(0, selEnd - lastBlock.position()); } mimeData->setText(content); return mimeData; } void OutputWindow::clear() { d->formatter.clear(); } void OutputWindow::flush() { const int totalQueuedSize = std::accumulate(d->queuedOutput.cbegin(), d->queuedOutput.cend(), 0, [](int val, const QPair &c) { return val + c.first.size(); }); if (totalQueuedSize > 5 * chunkSize) { d->flushRequested = true; return; } d->queueTimer.stop(); for (const auto &chunk : d->queuedOutput) handleOutputChunk(chunk.first, chunk.second); d->queuedOutput.clear(); d->formatter.flush(); } void OutputWindow::reset() { flush(); d->queueTimer.stop(); d->formatter.reset(); if (!d->queuedOutput.isEmpty()) { d->queuedOutput.clear(); d->formatter.appendMessage(tr("[Discarding excessive amount of pending output.]\n"), ErrorMessageFormat); } d->flushRequested = false; } void OutputWindow::scrollToBottom() { verticalScrollBar()->setValue(verticalScrollBar()->maximum()); // QPlainTextEdit destroys the first calls value in case of multiline // text, so make sure that the scroll bar actually gets the value set. // Is a noop if the first call succeeded. verticalScrollBar()->setValue(verticalScrollBar()->maximum()); } void OutputWindow::grayOutOldContent() { if (!d->cursor.atEnd()) d->cursor.movePosition(QTextCursor::End); QTextCharFormat endFormat = d->cursor.charFormat(); d->cursor.select(QTextCursor::Document); QTextCharFormat format; const QColor bkgColor = palette().base().color(); const QColor fgdColor = palette().text().color(); double bkgFactor = 0.50; double fgdFactor = 1.-bkgFactor; format.setForeground(QColor((bkgFactor * bkgColor.red() + fgdFactor * fgdColor.red()), (bkgFactor * bkgColor.green() + fgdFactor * fgdColor.green()), (bkgFactor * bkgColor.blue() + fgdFactor * fgdColor.blue()) )); d->cursor.mergeCharFormat(format); d->cursor.movePosition(QTextCursor::End); d->cursor.setCharFormat(endFormat); d->cursor.insertBlock(QTextBlockFormat()); } void OutputWindow::enableUndoRedo() { setMaximumBlockCount(0); setUndoRedoEnabled(true); } void OutputWindow::setWordWrapEnabled(bool wrap) { if (wrap) setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); else setWordWrapMode(QTextOption::NoWrap); } #ifdef WITH_TESTS // Handles all lines starting with "A" and the following ones up to and including the next // one starting with "A". class TestFormatterA : public OutputLineParser { private: Result handleLine(const QString &text, OutputFormat) override { static const QString replacement = "handled by A\n"; if (m_handling) { if (text.startsWith("A")) { m_handling = false; return {Status::Done, {}, replacement}; } return {Status::InProgress, {}, replacement}; } if (text.startsWith("A")) { m_handling = true; return {Status::InProgress, {}, replacement}; } return Status::NotHandled; } bool m_handling = false; }; // Handles all lines starting with "B". No continuation logic. class TestFormatterB : public OutputLineParser { private: Result handleLine(const QString &text, OutputFormat) override { if (text.startsWith("B")) return {Status::Done, {}, QString("handled by B\n")}; return Status::NotHandled; } }; void Internal::CorePlugin::testOutputFormatter() { const QString input = "B to be handled by B\r\n" "not to be handled\n" "A to be handled by A\n" "continuation for A\r\n" "B looks like B, but still continuation for A\r\n" "A end of A\n" "A next A\n" "A end of next A\n" " A trick\r\n" "line with \r embedded carriage return\n" "B to be handled by B\n"; const QString output = "handled by B\n" "not to be handled\n" "handled by A\n" "handled by A\n" "handled by A\n" "handled by A\n" "handled by A\n" "handled by A\n" " A trick\n" " embedded carriage return\n" "handled by B\n"; // Stress-test the implementation by providing the input in chunks, splitting at all possible // offsets. for (int i = 0; i < input.length(); ++i) { OutputFormatter formatter; QPlainTextEdit textEdit; formatter.setPlainTextEdit(&textEdit); formatter.setLineParsers({new TestFormatterB, new TestFormatterA}); formatter.appendMessage(input.left(i), StdOutFormat); formatter.appendMessage(input.mid(i), StdOutFormat); QCOMPARE(textEdit.toPlainText(), output); } } #endif // WITH_TESTS } // namespace Core