TextEdit large text: don't populate blocks outside the viewport into SG

When the text is larger than 10000 characters, we now avoid creating
nodes for blocks that go outside the viewport. Each time the text moves
relative to the viewport, we check whether the rectangular area that we
know is "covered" with the text rendered so far is still enough to fill
the viewport; if not, update the whole document: mark all existing SG
nodes dirty, then delete and re-create them.

[ChangeLog][QtQuick][Text] When given large text documents
(QString::size() > 10000), Text and TextEdit now try to avoid populating
scene graph nodes for ranges of text that fall outside the viewport,
which could be a parent item having the ItemIsViewport flag set (such as
a Flickable), or the window's content item. If the viewport is smaller
than the window, you might see lines of text disappearing when they are
scrolled out of the viewport; if that's undesired, either design your UI
so that other items obscure the area beyond the viewport, or set the
clip property to make clipping exact.

Task-number: QTBUG-90734
Change-Id: I9c88885b1ad3c3f24df0f7f322ed82d76b8e07c9
Reviewed-by: Eskil Abrahamsen Blomfeldt <eskil.abrahamsen-blomfeldt@qt.io>
This commit is contained in:
Shawn Rutledge 2021-10-26 11:50:33 +02:00
parent 4a5b0ad84f
commit 9db23e0e04
4 changed files with 244 additions and 12 deletions

View File

@ -66,6 +66,8 @@
QT_BEGIN_NAMESPACE
Q_DECLARE_LOGGING_CATEGORY(lcVP)
/*!
\qmltype TextEdit
\instantiates QQuickTextEdit
@ -124,6 +126,12 @@ TextEdit {
// into text nodes corresponding to a text block each so that the glyph node grouping doesn't become pointless.
static const int nodeBreakingSize = 300;
#if !defined(QQUICKTEXT_LARGETEXT_THRESHOLD)
#define QQUICKTEXT_LARGETEXT_THRESHOLD 10000
#endif
// if QString::size() > largeTextSizeThreshold, we render more often, but only visible lines
const int QQuickTextEditPrivate::largeTextSizeThreshold = QQUICKTEXT_LARGETEXT_THRESHOLD;
namespace {
class ProtectedLayoutAccessor: public QAbstractTextDocumentLayout
{
@ -424,6 +432,7 @@ void QQuickTextEdit::setText(const QString &text)
} else {
d->control->setPlainText(text);
}
setFlag(QQuickItem::ItemObservesViewport, text.size() > QQuickTextEditPrivate::largeTextSizeThreshold);
}
/*!
@ -804,6 +813,26 @@ void QQuickTextEditPrivate::mirrorChange()
}
}
bool QQuickTextEditPrivate::transformChanged(QQuickItem *transformedItem)
{
Q_Q(QQuickTextEdit);
qCDebug(lcVP) << q << "sees that" << transformedItem << "moved in VP" << q->clipRect();
// If there's a lot of text, and the TextEdit has been scrolled so that the viewport
// no longer completely covers the rendered region, we need QQuickTextEdit::updatePaintNode()
// to re-iterate blocks and populate a different range.
if (flags & QQuickItem::ItemObservesViewport) {
if (QQuickItem *viewport = q->viewportItem()) {
QRectF vp = q->mapRectFromItem(viewport, viewport->clipRect());
if (!(vp.top() > renderedRegion.top() && vp.bottom() < renderedRegion.bottom())) {
qCDebug(lcVP) << "viewport" << vp << "now goes beyond rendered region" << renderedRegion << "; updating";
q->updateWholeDocument();
}
}
}
return QQuickImplicitSizeItemPrivate::transformChanged(transformedItem);
}
#if QT_CONFIG(im)
Qt::InputMethodHints QQuickTextEditPrivate::effectiveInputMethodHints() const
{
@ -2083,6 +2112,13 @@ QSGNode *QQuickTextEdit::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *
} while (nodeIterator != d->textNodeMap.constEnd() && nodeIterator->textNode() != firstCleanNode);
}
// If there's a lot of text, insert only the range of blocks that can possibly be visible within the viewport.
QRectF viewport;
if (flags().testFlag(QQuickItem::ItemObservesViewport)) {
viewport = clipRect();
qCDebug(lcVP) << "text viewport" << viewport;
}
// FIXME: the text decorations could probably be handled separately (only updated for affected textFrames)
rootNode->resetFrameDecorations(d->createTextNode());
resetEngine(&frameDecorationsEngine, d->color, d->selectedTextColor, d->selectionColor);
@ -2103,6 +2139,9 @@ QSGNode *QQuickTextEdit::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *
QList<QTextFrame *> frames;
frames.append(d->document->rootFrame());
d->firstBlockInViewport = -1;
d->firstBlockPastViewport = -1;
while (!frames.isEmpty()) {
QTextFrame *textFrame = frames.takeFirst();
frames.append(textFrame->childFrames());
@ -2111,19 +2150,28 @@ QSGNode *QQuickTextEdit::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *
if (textFrame->lastPosition() < firstDirtyPos
|| textFrame->firstPosition() >= firstCleanNode.startPos())
continue;
node = d->createTextNode();
resetEngine(&engine, d->color, d->selectedTextColor, d->selectionColor);
if (textFrame->firstPosition() > textFrame->lastPosition()
&& textFrame->frameFormat().position() != QTextFrameFormat::InFlow) {
node = d->createTextNode();
updateNodeTransform(node, d->document->documentLayout()->frameBoundingRect(textFrame).topLeft());
const int pos = textFrame->firstPosition() - 1;
ProtectedLayoutAccessor *a = static_cast<ProtectedLayoutAccessor *>(d->document->documentLayout());
QTextCharFormat format = a->formatAccessor(pos);
QTextBlock block = textFrame->firstCursorPosition().block();
engine.setCurrentLine(block.layout()->lineForTextPosition(pos - block.position()));
engine.addTextObject(block, QPointF(0, 0), format, QQuickTextNodeEngine::Unselected, d->document,
pos, textFrame->frameFormat().position());
nodeOffset = d->document->documentLayout()->blockBoundingRect(block).topLeft();
bool inView = true;
if (!viewport.isNull() && block.layout()) {
QRectF coveredRegion = block.layout()->boundingRect().adjusted(nodeOffset.x(), nodeOffset.y(), nodeOffset.x(), nodeOffset.y());
inView = coveredRegion.bottom() >= viewport.top() && coveredRegion.top() <= viewport.bottom();
qCDebug(lcVP) << "non-flow frame" << coveredRegion << "in viewport?" << inView;
}
if (inView) {
engine.setCurrentLine(block.layout()->lineForTextPosition(pos - block.position()));
engine.addTextObject(block, QPointF(0, 0), format, QQuickTextNodeEngine::Unselected, d->document,
pos, textFrame->frameFormat().position());
}
nodeStart = pos;
} else {
// Having nodes spanning across frame boundaries will break the current bookkeeping mechanism. We need to prevent that.
@ -2140,29 +2188,62 @@ QSGNode *QQuickTextEdit::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *
if (block.position() < firstDirtyPos)
continue;
if (!engine.hasContents()) {
if (!engine.hasContents())
nodeOffset = d->document->documentLayout()->blockBoundingRect(block).topLeft();
updateNodeTransform(node, nodeOffset);
nodeStart = block.position();
bool inView = true;
if (!viewport.isNull()) {
QRectF coveredRegion;
if (block.layout()) {
coveredRegion = block.layout()->boundingRect().adjusted(nodeOffset.x(), nodeOffset.y(), nodeOffset.x(), nodeOffset.y());
inView = coveredRegion.bottom() > viewport.top();
}
if (d->firstBlockInViewport < 0 && inView) {
qCDebug(lcVP) << "first block in viewport" << block.blockNumber() << "@" << nodeOffset.y() << coveredRegion;
d->firstBlockInViewport = block.blockNumber();
if (block.layout())
d->renderedRegion = coveredRegion;
} else {
if (nodeOffset.y() > viewport.bottom()) {
inView = false;
if (d->firstBlockInViewport >= 0 && d->firstBlockPastViewport < 0) {
qCDebug(lcVP) << "first block past viewport" << viewport << block.blockNumber()
<< "@" << nodeOffset.y() << "total region rendered" << d->renderedRegion;
d->firstBlockPastViewport = block.blockNumber();
}
break; // skip rest of blocks in this frame
}
if (inView && !block.text().isEmpty() && coveredRegion.isValid())
d->renderedRegion = d->renderedRegion.united(coveredRegion);
}
}
engine.addTextBlock(d->document, block, -nodeOffset, d->color, QColor(), selectionStart(), selectionEnd() - 1);
currentNodeSize += block.length();
if (inView) {
if (!engine.hasContents()) {
node = d->createTextNode();
updateNodeTransform(node, nodeOffset);
nodeStart = block.position();
}
engine.addTextBlock(d->document, block, -nodeOffset, d->color, QColor(), selectionStart(), selectionEnd() - 1);
currentNodeSize += block.length();
}
if ((it.atEnd()) || block.next().position() >= firstCleanNode.startPos())
break; // last node that needed replacing or last block of the frame
QList<int>::const_iterator lowerBound = std::lower_bound(frameBoundaries.constBegin(), frameBoundaries.constEnd(), block.next().position());
if (currentNodeSize > nodeBreakingSize || lowerBound == frameBoundaries.constEnd() || *lowerBound > nodeStart) {
if (node && (currentNodeSize > nodeBreakingSize || lowerBound == frameBoundaries.constEnd() || *lowerBound > nodeStart)) {
currentNodeSize = 0;
d->addCurrentTextNodeToRoot(&engine, rootNode, node, nodeIterator, nodeStart);
if (!node->parent())
d->addCurrentTextNodeToRoot(&engine, rootNode, node, nodeIterator, nodeStart);
node = d->createTextNode();
resetEngine(&engine, d->color, d->selectedTextColor, d->selectionColor);
nodeStart = block.next().position();
}
}
}
d->addCurrentTextNodeToRoot(&engine, rootNode, node, nodeIterator, nodeStart);
if (Q_LIKELY(node && !node->parent()))
d->addCurrentTextNodeToRoot(&engine, rootNode, node, nodeIterator, nodeStart);
}
frameDecorationsEngine.addToSceneGraph(rootNode->frameDecorationsNode, QQuickText::Normal, QColor());
// Now prepend the frame decorations since we want them rendered first, with the text nodes and cursor in front.

View File

@ -142,6 +142,7 @@ public:
bool determineHorizontalAlignment();
bool setHAlign(QQuickTextEdit::HAlignment, bool forceAlign = false);
void mirrorChange() override;
bool transformChanged(QQuickItem *transformedItem) override;
qreal getImplicitWidth() const override;
Qt::LayoutDirection textDirection(const QString &text) const;
bool isLinkHoveredConnected();
@ -189,6 +190,9 @@ public:
int lastSelectionStart;
int lastSelectionEnd;
int lineCount;
int firstBlockInViewport = -1;
int firstBlockPastViewport = -1;
QRectF renderedRegion;
enum UpdateType {
UpdateNone,
@ -226,6 +230,8 @@ public:
bool selectByKeyboardSet:1;
bool hadSelection : 1;
bool markdownText : 1;
static const int largeTextSizeThreshold;
};
QT_END_NAMESPACE

View File

@ -0,0 +1,26 @@
import QtQuick
Item {
width: 480; height: 480
Rectangle {
id: viewport
anchors.fill: parent
anchors.margins: 100
border.color: "red"
TextEdit {
font.pointSize: 10
cursorDelegate: Rectangle {
border.color: "green"
border.width: 2
color: "transparent"
width: 10
}
Component.onCompleted: {
for (let i = 0; i < 25; ++i)
text += "Line " + i + "\n";
}
}
}
}

View File

@ -30,6 +30,7 @@
#include <QtQuickTestUtils/private/testhttpserver_p.h>
#include <math.h>
#include <QFile>
#include <QtQuick/QQuickTextDocument>
#include <QTextDocument>
#include <QtQml/qqmlengine.h>
#include <QtQml/qqmlcontext.h>
@ -179,6 +180,8 @@ private slots:
void clipRect();
void implicitSizeBinding_data();
void implicitSizeBinding();
void largeTextObservesViewport_data();
void largeTextObservesViewport();
void signal_editingfinished();
@ -3684,6 +3687,122 @@ void tst_qquicktextedit::implicitSizeBinding()
QCOMPARE(textObject->height(), textObject->implicitHeight());
}
void tst_qquicktextedit::largeTextObservesViewport_data()
{
QTest::addColumn<QString>("text");
QTest::addColumn<QQuickTextEdit::TextFormat>("textFormat");
QTest::addColumn<bool>("parentIsViewport");
QTest::addColumn<int>("cursorPos");
QTest::addColumn<int>("expectedBlockTolerance");
QTest::addColumn<int>("expectedBlocksAboveViewport");
QTest::addColumn<int>("expectedBlocksPastViewport");
QTest::addColumn<int>("expectedRenderedRegionMin");
QTest::addColumn<int>("expectedRenderedRegionMax");
QString text;
{
QStringList lines;
// "line 100" is 8 characters; many lines are longer, some are shorter
// so we populate 1250 lines, 11389 characters
const int lineCount = QQuickTextEditPrivate::largeTextSizeThreshold / 8;
lines.reserve(lineCount);
for (int i = 0; i < lineCount; ++i)
lines << QLatin1String("line ") + QString::number(i);
text = lines.join('\n');
}
Q_ASSERT(text.size() > QQuickTextEditPrivate::largeTextSizeThreshold);
// by default, the root item acts as the viewport:
// QQuickTextEdit doesn't populate lines of text beyond the bottom of the window
// cursor position 1000 is on line 121
QTest::newRow("default plain text") << text << QQuickTextEdit::PlainText << false << 1000 << 4 << 115 << 147 << 1700 << 2700;
// make the rectangle into a viewport item, and move the text upwards:
// QQuickTextEdit doesn't populate lines of text beyond the bottom of the viewport rectangle
QTest::newRow("clipped plain text") << text << QQuickTextEdit::PlainText << true << 1000 << 4 << 123 << 141 << 1800 << 2600;
{
QStringList lines;
// "line 100" is 8 characters; many lines are longer, some are shorter
// so we populate 1250 lines, 11389 characters
const int lineCount = QQuickTextEditPrivate::largeTextSizeThreshold / 8;
lines.reserve(lineCount);
// add a table (of contents, perhaps): ensure that doesn't get included in renderedRegion after we've scrolled past it
lines << QLatin1String("<table border='1'><tr><td>Chapter 1<td></tr><tr><td>Chapter 2</td></tr><tr><td>etc</td></tr></table>");
for (int i = 0; i < lineCount; ++i) {
if (i > 0 && i % 50 == 0)
// chapter heading with floating image: ensure that doesn't get included in renderedRegion after we've scrolled past it
lines << QLatin1String("<img style='float:left;' src='http/exists.png' height='32'/><h1>chapter ") +
QString::number(i / 50) + QLatin1String("</h1>");
lines << QLatin1String("<p>line ") + QString::number(i) + QLatin1String("</p>");
}
text = lines.join('\n');
}
Q_ASSERT(text.size() > QQuickTextEditPrivate::largeTextSizeThreshold);
// by default, the root item acts as the viewport:
// QQuickTextEdit doesn't populate blocks beyond the bottom of the window
QTest::newRow("default styled text") << text << QQuickTextEdit::RichText << false << 1000 << 4 << 123 << 141 << 3200 << 4100;
// make the rectangle into a viewport item, and move the text upwards:
// QQuickTextEdit doesn't populate blocks that don't intersect the viewport rectangle
QTest::newRow("clipped styled text") << text << QQuickTextEdit::RichText << true << 1000 << 4 << 127 << 138 << 3300 << 4100;
// get the "chapter 2" heading into the viewport
QTest::newRow("heading visible") << text << QQuickTextEdit::RichText << true << 780 << 4 << 102 << 112 << 2600 << 3300;
}
void tst_qquicktextedit::largeTextObservesViewport()
{
if ((QGuiApplication::platformName() == QLatin1String("offscreen"))
|| (QGuiApplication::platformName() == QLatin1String("minimal")))
QSKIP("Skipping due to few fonts installed on offscreen/minimal platforms");
QFETCH(QString, text);
QFETCH(QQuickTextEdit::TextFormat, textFormat);
QFETCH(bool, parentIsViewport);
QFETCH(int, cursorPos);
QFETCH(int, expectedBlockTolerance);
QFETCH(int, expectedBlocksAboveViewport);
QFETCH(int, expectedBlocksPastViewport);
QFETCH(int, expectedRenderedRegionMin);
QFETCH(int, expectedRenderedRegionMax);
QQuickView window;
QByteArray errorMessage;
QVERIFY2(QQuickTest::initView(window, testFileUrl("viewport.qml"), true, &errorMessage), errorMessage.constData());
window.show();
QVERIFY(QTest::qWaitForWindowExposed(&window));
QQuickTextEdit *textItem = window.rootObject()->findChild<QQuickTextEdit*>();
QVERIFY(textItem);
QQuickItem *viewportItem = textItem->parentItem();
QQuickTextEditPrivate *textPriv = QQuickTextEditPrivate::get(textItem);
viewportItem->setFlag(QQuickItem::ItemIsViewport, parentIsViewport);
textItem->setTextFormat(textFormat);
textItem->setText(text);
textItem->setFocus(true);
if (lcTests().isDebugEnabled())
QTest::qWait(1000);
textItem->setCursorPosition(cursorPos);
auto cursorRect = textItem->cursorRectangle();
textItem->setY(-cursorRect.top());
qCDebug(lcTests) << "text size" << textItem->text().size() << "lines" << textItem->lineCount() << "font" << textItem->font();
Q_ASSERT(textItem->text().size() > QQuickTextEditPrivate::largeTextSizeThreshold);
QVERIFY(textItem->flags().testFlag(QQuickItem::ItemObservesViewport)); // large text sets this flag automatically
QCOMPARE(textItem->viewportItem(), parentIsViewport ? viewportItem : viewportItem->parentItem());
QTRY_VERIFY(textPriv->firstBlockInViewport > 0); // wait for rendering
qCDebug(lcTests) << "first block rendered" << textPriv->firstBlockInViewport
<< "expected" << expectedBlocksAboveViewport
<< "first block past viewport" << textPriv->firstBlockPastViewport
<< "expected" << expectedBlocksPastViewport
<< "region" << textPriv->renderedRegion
<< "expected range" << expectedRenderedRegionMin << expectedRenderedRegionMax;
if (lcTests().isDebugEnabled())
QTest::qWait(1000);
QVERIFY(qAbs(textPriv->firstBlockInViewport - expectedBlocksAboveViewport) < expectedBlockTolerance);
QVERIFY(qAbs(textPriv->firstBlockPastViewport - expectedBlocksPastViewport) < expectedBlockTolerance);
QVERIFY(textPriv->renderedRegion.top() > expectedRenderedRegionMin);
QVERIFY(textPriv->renderedRegion.bottom() < expectedRenderedRegionMax);
}
void tst_qquicktextedit::signal_editingfinished()
{
QQuickView *window = new QQuickView(nullptr);