feat: add optional improved text wrapping (#6265)

See the cmake flag `CHATTERINO_ALLOW_PRIVATE_QT_API` for details on how to test this.
This commit is contained in:
nerix
2025-07-20 17:01:06 +02:00
committed by GitHub
parent 1ed5a32fd1
commit 971c380681
5 changed files with 97 additions and 3 deletions
+88 -3
View File
@@ -20,6 +20,10 @@
#include <QJsonObject>
#include <QJsonValue>
#ifdef CHATTERINO_WITH_PRIVATE_QT_API
# include <QtGui/private/qtextengine_p.h>
#endif
namespace chatterino {
using namespace literals;
@@ -621,13 +625,93 @@ void TextElement::addToContainer(MessageLayoutContainer &container,
}
}
// we done goofed, we need to wrap the text
// We done goofed, we need to wrap the text.
// If we allow the use of private Qt APIs, we can use Qt's text
// engine to accurately calculate the width of the text. Otherwise,
// we have to fall back to using horizontalAdvance which has some
// corner cases when processing whole words (see #5944).
#ifdef CHATTERINO_WITH_PRIVATE_QT_API
auto font =
app->getFonts()->getFont(this->style_, container.getScale());
// This code is similar to the one from QTextEngine::elidedText in
// the mode Qt::ElideRight (because that's essentially what we're
// doing here): https://github.com/qt/qtbase/blob/560bf5a07720eaa8cc589f424743db8ed1f1d902/src/gui/text/qtextengine.cpp#L3145
// A difference is that, once we detected EOL, we start again.
// The start of the current line in `word`
qsizetype actualStart = 0;
// This is treated like a view (from `actualStart`) over the word.
// It's a QString because QStackTextEngine doesn't support
// QStringViews as arguments.
QString view = word;
// This is essentially a loop over every line of text.
do
{
QStackTextEngine engine(view, font);
engine.validate(); // initialize the internal state
int pos = 0;
int nextBreak = 0;
QFixed currentWidth = 0;
int to = static_cast<int>(view.size());
bool needsBreak = false;
// Find the next grapheme boundary (`nextBreak`) at which we
// need to break because the text wouldn't fit into the
// container anymore.
do
{
pos = nextBreak;
++nextBreak;
while (nextBreak < engine.layoutData->string.size() &&
!engine.attributes()[nextBreak].graphemeBoundary)
{
++nextBreak;
}
auto nextWidth =
currentWidth + engine.width(pos, nextBreak - pos);
if (!container.fitsInLine(nextWidth.toReal()))
{
needsBreak = true;
if (pos == 0)
{
// Make sure that we consume at least one glyph.
// So this element will overflow
currentWidth = nextWidth;
}
else
{
// We didn't consume the glyph, it's for the next line
nextBreak = pos;
}
break;
}
currentWidth = nextWidth;
} while (nextBreak < to);
// Now we either processed the whole text or we need to break
container.addElementNoLineBreak(getTextLayoutElement(
word.sliced(actualStart, nextBreak), currentWidth.toReal(),
!needsBreak && this->hasTrailingSpace()));
if (needsBreak)
{
container.breakLine();
}
actualStart += nextBreak;
// Update the view
view = QString::fromRawData(word.constData() + actualStart,
word.size() - actualStart);
assert(needsBreak || view.isEmpty());
} while (!view.isEmpty());
#else
auto textLength = word.length();
int wordStart = 0;
width = 0;
// QChar::isHighSurrogate(text[0].unicode()) ? 2 : 1
for (int i = 0; i < textLength; i++)
{
auto isSurrogate = word.size() > i + 1 &&
@@ -663,6 +747,7 @@ void TextElement::addToContainer(MessageLayoutContainer &container,
//add the final piece of wrapped text
container.addElementNoLineBreak(getTextLayoutElement(
word.mid(wordStart), width, this->hasTrailingSpace()));
#endif
}
}
}