Files
qt-creator/src/plugins/coreplugin/outputwindow.cpp

593 lines
19 KiB
C++
Raw Normal View History

/****************************************************************************
**
** 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 <utils/outputformatter.h>
#include <QAction>
#include <QCursor>
#include <QMimeData>
#include <QPointer>
#include <QRegularExpression>
#include <QScrollBar>
#include <QTextBlock>
#ifdef WITH_TESTS
#include <QtTest>
#endif
using namespace Utils;
namespace Core {
namespace Internal {
class OutputWindowPrivate
{
public:
explicit OutputWindowPrivate(QTextDocument *document)
: cursor(document)
{
}
~OutputWindowPrivate()
{
ICore::removeContextObject(outputWindowContext);
delete outputWindowContext;
}
IContext *outputWindowContext = nullptr;
QString settingsKey;
OutputFormatter formatter;
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;
};
} // 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->settingsKey = settingsKey;
d->outputWindowContext = new IContext;
d->outputWindowContext->setContext(context);
d->outputWindowContext->setWidget(this);
ICore::addContextObject(d->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);
m_scrollTimer.setInterval(10);
m_scrollTimer.setSingleShot(true);
connect(&m_scrollTimer, &QTimer::timeout,
this, &OutputWindow::scrollToBottom);
m_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::mouseReleaseEvent(QMouseEvent *e)
{
if (d->linksActive && d->mouseButtonPressed == Qt::LeftButton) {
const QString href = anchorAt(e->pos());
d->formatter.handleLink(href);
}
// 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<OutputLineParser *> &parsers)
{
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::setMaxCharCount(int count)
{
d->maxCharCount = count;
setMaximumBlockCount(count / 100);
}
int OutputWindow::maxCharCount() const
{
return d->maxCharCount;
}
void OutputWindow::appendMessage(const QString &output, OutputFormat format)
{
QString out = output;
if (out.size() > d->maxCharCount) {
// Current line alone exceeds limit, we need to cut it.
out.truncate(d->maxCharCount);
out.append("[...]");
setMaximumBlockCount(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() || m_scrollTimer.isActive();
d->scrollToBottom = true;
d->formatter.appendMessage(out, format);
if (atBottom) {
if (m_lastMessage.elapsed() < 5) {
m_scrollTimer.start();
} else {
m_scrollTimer.stop();
scrollToBottom();
}
}
m_lastMessage.start();
enableUndoRedo();
}
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()
{
d->formatter.flush();
}
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