diff --git a/.clang-tidy b/.clang-tidy index 7bee4c0b..f06e4e79 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -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, diff --git a/CHANGELOG.md b/CHANGELOG.md index ca00fef3..691641f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/CMakeLists.txt b/CMakeLists.txt index e00856bd..95039fd6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ed17108a..960f4320 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 835754b6..26d12dcc 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -20,6 +20,10 @@ #include #include +#ifdef CHATTERINO_WITH_PRIVATE_QT_API +# include +#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(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 } } }