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
+1
View File
@@ -19,6 +19,7 @@ Checks: "-*,
-cppcoreguidelines-owning-memory,
-cppcoreguidelines-avoid-magic-numbers,
-cppcoreguidelines-avoid-const-or-ref-data-members,
-cppcoreguidelines-avoid-do-while,
-readability-magic-numbers,
-performance-noexcept-move-constructor,
-misc-non-private-member-variables-in-classes,
+1
View File
@@ -81,6 +81,7 @@
- Dev: Emoji style / set is now stored lowercase (and matched case-insensitively). Changing emoji style from this point on and then running an old version might mean you will use the Twitter emoji style by default. (#6300)
- Dev: Refactored `OnceFlag`. (#6237, #6316)
- Dev: Bumped clang-format requirement to 19. (#6236)
- Dev: Added optional improved text wrapping through private Qt APIs. (#6265)
- Dev: Factored out AUMID to `Version`. (#6321)
- Dev: Silenced some warnings when compiling with clang-cl. (#6331)
- Dev: Added some commands for forcing a relayout (and related things) in channel views. (#6342)
+1
View File
@@ -30,6 +30,7 @@ option(BUILD_SHARED_LIBS "" OFF)
option(CHATTERINO_LTO "Enable LTO for all targets" OFF)
option(CHATTERINO_PLUGINS "Enable ALPHA plugin support in Chatterino" ON)
option(CHATTERINO_USE_GDI_FONTENGINE "Use the legacy GDI fontengine instead of the new DirectWrite one on Windows (Qt 6.8.0 and later)" ON)
option(CHATTERINO_ALLOW_PRIVATE_QT_API "Allow uses of Qt's private API - when enabling this, Chatterino must use the EXACT Qt version it was compiled against" OFF)
option(CHATTERINO_UPDATER "Enable update checks" ON)
mark_as_advanced(CHATTERINO_UPDATER)
+6
View File
@@ -872,6 +872,12 @@ if (CHATTERINO_PLUGINS)
target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua sol2::sol2)
endif()
if (CHATTERINO_ALLOW_PRIVATE_QT_API)
target_link_libraries(${LIBRARY_PROJECT} PUBLIC Qt${MAJOR_QT_VERSION}::GuiPrivate)
target_compile_definitions(${LIBRARY_PROJECT} PUBLIC
CHATTERINO_WITH_PRIVATE_QT_API)
endif()
if (BUILD_WITH_QTKEYCHAIN)
target_link_libraries(${LIBRARY_PROJECT}
PUBLIC
+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
}
}
}