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:
parent
4a5b0ad84f
commit
9db23e0e04
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
Loading…
Reference in New Issue