chore: cleanup, document, and test some RTL code (#5473)

This commit is contained in:
nerix 2024-07-27 13:19:26 +02:00 committed by GitHub
parent a2cbe6377d
commit ff7cc09f8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 413 additions and 104 deletions

View file

@ -55,6 +55,7 @@
- Dev: Deprecate Qt 5.12. (#5396) - Dev: Deprecate Qt 5.12. (#5396)
- Dev: The running Qt version is now shown in the about page if it differs from the compiled version. (#5501) - Dev: The running Qt version is now shown in the about page if it differs from the compiled version. (#5501)
- Dev: `FlagsEnum` is now `constexpr`. (#5510) - Dev: `FlagsEnum` is now `constexpr`. (#5510)
- Dev: Documented and added tests to RTL handling. (#5473)
## 2.5.1 ## 2.5.1

View file

@ -53,24 +53,27 @@ Frames::Frames(QList<Frame> &&frames)
getApp()->getEmotes()->getGIFTimer().signal.connect([this] { getApp()->getEmotes()->getGIFTimer().signal.connect([this] {
this->advance(); this->advance();
}); });
auto totalLength =
std::accumulate(this->items_.begin(), this->items_.end(), 0UL,
[](auto init, auto &&frame) {
return init + frame.duration;
});
if (totalLength == 0)
{
this->durationOffset_ = 0;
}
else
{
this->durationOffset_ = std::min<int>(
int(getApp()->getEmotes()->getGIFTimer().position() %
totalLength),
60000);
}
this->processOffset();
} }
auto totalLength = std::accumulate(this->items_.begin(), this->items_.end(),
0UL, [](auto init, auto &&frame) {
return init + frame.duration;
});
if (totalLength == 0)
{
this->durationOffset_ = 0;
}
else
{
this->durationOffset_ = std::min<int>(
int(getApp()->getEmotes()->getGIFTimer().position() % totalLength),
60000);
}
this->processOffset();
DebugCount::increase("image bytes", this->memoryUsage()); DebugCount::increase("image bytes", this->memoryUsage());
DebugCount::increase("image bytes (ever loaded)", this->memoryUsage()); DebugCount::increase("image bytes (ever loaded)", this->memoryUsage());
} }

View file

@ -593,8 +593,6 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container,
// once we encounter an emote or reach the end of the message text. */ // once we encounter an emote or reach the end of the message text. */
QString currentText; QString currentText;
container.first = FirstWord::Neutral;
bool firstIteration = true; bool firstIteration = true;
for (Word &word : this->words_) for (Word &word : this->words_)
{ {

View file

@ -119,9 +119,9 @@ private:
QPixmap *ensureBuffer(QPainter &painter, int width); QPixmap *ensureBuffer(QPainter &painter, int width);
// variables // variables
MessagePtr message_; const MessagePtr message_;
MessageLayoutContainer container_; MessageLayoutContainer container_;
std::unique_ptr<QPixmap> buffer_{}; std::unique_ptr<QPixmap> buffer_;
bool bufferValid_ = false; bool bufferValid_ = false;
int height_ = 0; int height_ = 0;

View file

@ -1,4 +1,4 @@
#include "MessageLayoutContainer.hpp" #include "messages/layouts/MessageLayoutContainer.hpp"
#include "Application.hpp" #include "Application.hpp"
#include "messages/layouts/MessageLayoutContext.hpp" #include "messages/layouts/MessageLayoutContext.hpp"
@ -14,6 +14,7 @@
#include <QDebug> #include <QDebug>
#include <QMargins> #include <QMargins>
#include <QPainter> #include <QPainter>
#include <QVarLengthArray>
#include <optional> #include <optional>
@ -55,7 +56,6 @@ void MessageLayoutContainer::beginLayout(int width, float scale,
this->currentWordId_ = 0; this->currentWordId_ = 0;
this->canAddMessages_ = true; this->canAddMessages_ = true;
this->isCollapsed_ = false; this->isCollapsed_ = false;
this->wasPrevReversed_ = false;
} }
void MessageLayoutContainer::endLayout() void MessageLayoutContainer::endLayout()
@ -71,7 +71,7 @@ void MessageLayoutContainer::endLayout()
QSize(this->dotdotdotWidth_, this->textLineHeight_), QSize(this->dotdotdotWidth_, this->textLineHeight_),
QColor("#00D80A"), FontStyle::ChatMediumBold, this->scale_); QColor("#00D80A"), FontStyle::ChatMediumBold, this->scale_);
if (this->first == FirstWord::RTL) if (this->isRTL())
{ {
// Shift all elements in the next line to the left // Shift all elements in the next line to the left
for (auto i = this->lines_.back().startIndex; for (auto i = this->lines_.back().startIndex;
@ -125,9 +125,9 @@ void MessageLayoutContainer::addElementNoLineBreak(
void MessageLayoutContainer::breakLine() void MessageLayoutContainer::breakLine()
{ {
if (this->containsRTL) if (this->lineContainsRTL_ || this->isRTL())
{ {
for (int i = 0; i < this->elements_.size(); i++) for (size_t i = 0; i < this->elements_.size(); i++)
{ {
if (this->elements_[i]->getFlags().has( if (this->elements_[i]->getFlags().has(
MessageElementFlag::Username)) MessageElementFlag::Username))
@ -136,6 +136,7 @@ void MessageLayoutContainer::breakLine()
break; break;
} }
} }
this->lineContainsRTL_ = false;
} }
int xOffset = 0; int xOffset = 0;
@ -404,7 +405,7 @@ size_t MessageLayoutContainer::getSelectionIndex(QPoint point) const
size_t index = 0; size_t index = 0;
for (auto i = 0; i < lineEnd; i++) for (size_t i = 0; i < lineEnd; i++)
{ {
auto &&element = this->elements_[i]; auto &&element = this->elements_[i];
@ -565,30 +566,37 @@ int MessageLayoutContainer::nextWordId()
void MessageLayoutContainer::addElement(MessageLayoutElement *element, void MessageLayoutContainer::addElement(MessageLayoutElement *element,
const bool forceAdd, const bool forceAdd,
const int prevIndex) const qsizetype prevIndex)
{ {
if (!this->canAddElements() && !forceAdd) if (!this->canAddElements() && !forceAdd)
{ {
assert(prevIndex == -2 &&
"element is still referenced in this->elements_");
delete element; delete element;
return; return;
} }
bool isRTLMode = this->first == FirstWord::RTL && prevIndex != -2;
bool isAddingMode = prevIndex == -2; bool isAddingMode = prevIndex == -2;
bool isRTLAdjusting = this->isRTL() && !isAddingMode;
// This lambda contains the logic for when to step one 'space width' back for compact x emotes /// Returns `true` if a previously added `spaceWidth_` should be removed
auto shouldRemoveSpaceBetweenEmotes = [this, prevIndex]() -> bool { /// before the to be added emote. The space was inserted by the
/// previous element but isn't desired as "removeSpacesBetweenEmotes" is
/// enabled and both elements are emotes.
auto shouldRemoveSpaceBetweenEmotes = [this, prevIndex,
isAddingMode]() -> bool {
if (prevIndex == -1 || this->elements_.empty()) if (prevIndex == -1 || this->elements_.empty())
{ {
// No previous element found // No previous element found
return false; return false;
} }
const auto &lastElement = prevIndex == -2 ? this->elements_.back() const auto &lastElement =
: this->elements_[prevIndex]; isAddingMode ? this->elements_.back() : this->elements_[prevIndex];
if (!lastElement) if (!lastElement)
{ {
assert(false && "Empty element in container found");
return false; return false;
} }
@ -608,23 +616,24 @@ void MessageLayoutContainer::addElement(MessageLayoutElement *element,
return lastElement->getFlags().has(MessageElementFlag::EmoteImages); return lastElement->getFlags().has(MessageElementFlag::EmoteImages);
}; };
if (element->getText().isRightToLeft()) bool isRTLElement = element->getText().isRightToLeft();
if (isRTLElement)
{ {
this->containsRTL = true; this->lineContainsRTL_ = true;
} }
// check the first non-neutral word to see if we should render RTL or LTR // check the first non-neutral word to see if we should render RTL or LTR
if (isAddingMode && this->first == FirstWord::Neutral && if (isAddingMode && this->isNeutral() &&
element->getFlags().has(MessageElementFlag::Text) && element->getFlags().has(MessageElementFlag::Text) &&
!element->getFlags().has(MessageElementFlag::RepliedMessage)) !element->getFlags().has(MessageElementFlag::RepliedMessage))
{ {
if (element->getText().isRightToLeft()) if (isRTLElement)
{ {
this->first = FirstWord::RTL; this->textDirection_ = TextDirection::RTL;
} }
else if (!isNeutral(element->getText())) else if (!chatterino::isNeutral(element->getText()))
{ {
this->first = FirstWord::LTR; this->textDirection_ = TextDirection::LTR;
} }
} }
@ -665,7 +674,7 @@ void MessageLayoutContainer::addElement(MessageLayoutElement *element,
shouldRemoveSpaceBetweenEmotes()) shouldRemoveSpaceBetweenEmotes())
{ {
// Move cursor one 'space width' to the left (right in case of RTL) to combine hug the previous emote // Move cursor one 'space width' to the left (right in case of RTL) to combine hug the previous emote
if (isRTLMode) if (isRTLAdjusting)
{ {
this->currentX_ += this->spaceWidth_; this->currentX_ += this->spaceWidth_;
} }
@ -675,7 +684,7 @@ void MessageLayoutContainer::addElement(MessageLayoutElement *element,
} }
} }
if (isRTLMode) if (isRTLAdjusting)
{ {
// shift by width since we are calculating according to top right in RTL mode // shift by width since we are calculating according to top right in RTL mode
// but setPosition wants top left // but setPosition wants top left
@ -697,7 +706,7 @@ void MessageLayoutContainer::addElement(MessageLayoutElement *element,
} }
// set current x // set current x
if (isRTLMode) if (isRTLAdjusting)
{ {
this->currentX_ -= element->getRect().width(); this->currentX_ -= element->getRect().width();
} }
@ -708,7 +717,7 @@ void MessageLayoutContainer::addElement(MessageLayoutElement *element,
if (element->hasTrailingSpace()) if (element->hasTrailingSpace())
{ {
if (isRTLMode) if (isRTLAdjusting)
{ {
this->currentX_ -= this->spaceWidth_; this->currentX_ -= this->spaceWidth_;
} }
@ -719,15 +728,15 @@ void MessageLayoutContainer::addElement(MessageLayoutElement *element,
} }
} }
void MessageLayoutContainer::reorderRTL(int firstTextIndex) void MessageLayoutContainer::reorderRTL(size_t firstTextIndex)
{ {
if (this->elements_.empty()) if (this->elements_.empty())
{ {
return; return;
} }
int startIndex = static_cast<int>(this->lineStart_); size_t startIndex = this->lineStart_;
int endIndex = static_cast<int>(this->elements_.size()) - 1; size_t endIndex = this->elements_.size() - 1;
if (firstTextIndex >= endIndex || startIndex >= this->elements_.size()) if (firstTextIndex >= endIndex || startIndex >= this->elements_.size())
{ {
@ -735,64 +744,53 @@ void MessageLayoutContainer::reorderRTL(int firstTextIndex)
} }
startIndex = std::max(startIndex, firstTextIndex); startIndex = std::max(startIndex, firstTextIndex);
std::vector<int> correctSequence; QVarLengthArray<size_t, 32> correctSequence;
std::stack<int> swappedSequence; // temporary buffer to store elements in opposite order
QVarLengthArray<size_t, 32> swappedSequence;
// we reverse a sequence of words if it's opposite to the text direction bool isReversing = false;
// the second condition below covers the possible three cases: for (size_t i = startIndex; i <= endIndex; i++)
// 1 - if we are in RTL mode (first non-neutral word is RTL)
// we render RTL, reversing LTR sequences,
// 2 - if we are in LTR mode (first non-neutral word is LTR or all words are neutral)
// we render LTR, reversing RTL sequences
// 3 - neutral words follow previous words, we reverse a neutral word when the previous word was reversed
// the first condition checks if a neutral word is treated as a RTL word
// this is used later to add U+202B (RTL embedding) character signal to
// fix punctuation marks and mixing embedding LTR in an RTL word
// this can happen in two cases:
// 1 - in RTL mode, the previous word should be RTL (i.e. not reversed)
// 2 - in LTR mode, the previous word should be RTL (i.e. reversed)
for (int i = startIndex; i <= endIndex; i++)
{ {
auto &element = this->elements_[i]; auto &element = this->elements_[i];
const auto neutral = isNeutral(element->getText()); const auto neutral = chatterino::isNeutral(element->getText());
const auto neutralOrUsername = const auto neutralOrUsername =
neutral || element->getFlags().has(MessageElementFlag::Mention); neutral || element->getFlags().has(MessageElementFlag::Mention);
// check if neutral words are treated as RTL to add U+202B (RTL
// embedding) which fixes punctuation marks
if (neutral && if (neutral &&
((this->first == FirstWord::RTL && !this->wasPrevReversed_) || ((this->isRTL() && !isReversing) || (this->isLTR() && isReversing)))
(this->first == FirstWord::LTR && this->wasPrevReversed_)))
{ {
element->reversedNeutral = true; element->reversedNeutral = true;
} }
if (((element->getText().isRightToLeft() !=
(this->first == FirstWord::RTL)) && if ((element->getText().isRightToLeft() != this->isRTL() &&
!neutralOrUsername) || !neutralOrUsername) ||
(neutralOrUsername && this->wasPrevReversed_)) (neutralOrUsername && isReversing))
{ {
swappedSequence.push(i); swappedSequence.append(i);
this->wasPrevReversed_ = true; isReversing = true;
} }
else else
{ {
while (!swappedSequence.empty()) while (!swappedSequence.empty())
{ {
correctSequence.push_back(swappedSequence.top()); correctSequence.push_back(swappedSequence.last());
swappedSequence.pop(); swappedSequence.pop_back();
} }
correctSequence.push_back(i); correctSequence.push_back(i);
this->wasPrevReversed_ = false; isReversing = false;
} }
} }
while (!swappedSequence.empty()) while (!swappedSequence.empty())
{ {
correctSequence.push_back(swappedSequence.top()); correctSequence.push_back(swappedSequence.last());
swappedSequence.pop(); swappedSequence.pop_back();
} }
// render right to left if we are in RTL mode, otherwise LTR // render right to left if we are in RTL mode, otherwise LTR
if (this->first == FirstWord::RTL) if (this->isRTL())
{ {
this->currentX_ = this->elements_[endIndex]->getRect().right(); this->currentX_ = this->elements_[endIndex]->getRect().right();
} }
@ -806,10 +804,11 @@ void MessageLayoutContainer::reorderRTL(int firstTextIndex)
this->addElement(this->elements_[correctSequence[0]].get(), false, -1); this->addElement(this->elements_[correctSequence[0]].get(), false, -1);
} }
for (int i = 1; i < correctSequence.size() && this->canAddElements(); i++) for (qsizetype i = 1; i < correctSequence.size() && this->canAddElements();
i++)
{ {
this->addElement(this->elements_[correctSequence[i]].get(), false, this->addElement(this->elements_[correctSequence[i]].get(), false,
correctSequence[i - 1]); static_cast<qsizetype>(correctSequence[i - 1]));
} }
} }
@ -992,4 +991,19 @@ bool MessageLayoutContainer::canCollapse() const
this->flags_.has(MessageFlag::Collapsed); this->flags_.has(MessageFlag::Collapsed);
} }
bool MessageLayoutContainer::isRTL() const noexcept
{
return this->textDirection_ == TextDirection::RTL;
}
bool MessageLayoutContainer::isLTR() const noexcept
{
return this->textDirection_ == TextDirection::LTR;
}
bool MessageLayoutContainer::isNeutral() const noexcept
{
return this->textDirection_ == TextDirection::Neutral;
}
} // namespace chatterino } // namespace chatterino

View file

@ -10,12 +10,21 @@
#include <optional> #include <optional>
#include <vector> #include <vector>
#if __has_include(<gtest/gtest_prod.h>)
# include <gtest/gtest_prod.h>
#endif
class QPainter; class QPainter;
namespace chatterino { namespace chatterino {
enum class TextDirection : uint8_t {
Neutral,
RTL,
LTR,
};
enum class MessageFlag : int64_t; enum class MessageFlag : int64_t;
enum class FirstWord { Neutral, RTL, LTR };
using MessageFlags = FlagsEnum<MessageFlag>; using MessageFlags = FlagsEnum<MessageFlag>;
class MessageLayoutElement; class MessageLayoutElement;
struct Selection; struct Selection;
@ -24,8 +33,6 @@ struct MessagePaintContext;
struct MessageLayoutContainer { struct MessageLayoutContainer {
MessageLayoutContainer() = default; MessageLayoutContainer() = default;
FirstWord first = FirstWord::Neutral;
/** /**
* Begin the layout process of this message * Begin the layout process of this message
* *
@ -212,24 +219,54 @@ private:
QRect rect; QRect rect;
}; };
/* /// @brief Attempts to add @a element to this container
addElement is called at two stages. first stage is the normal one where we want to add message layout elements to the container. ///
If we detect an RTL word in the message, reorderRTL will be called, which is the second stage, where we call _addElement /// This can be called in two scenarios.
again for each layout element, but in the correct order this time, without adding the elemnt to the this->element_ vector. ///
Due to compact emote logic, we need the previous element to check if we should change the spacing or not. /// 1. **Regular**: In this scenario, @a element is positioned and added
in stage one, this is simply elements_.back(), but in stage 2 that's not the case due to the reordering, and we need to pass the /// to the internal container.
index of the reordered previous element. /// This is active iff @a prevIndex is `-2`.
In stage one we don't need that and we pass -2 to indicate stage one (i.e. adding mode) /// During this stage, if there isn't any @a textDirection_ detected yet,
In stage two, we pass -1 for the first element, and the index of the oredered privous element for the rest. /// the added element is checked if it contains RTL/LTR text to infer the
*/ /// direction. Only upon calling @a breakLine, the elements will be
/// visually reorderd.
///
/// 2. **Repositioning**: In this scenario, @a element is already added to
/// the container thus it's only repositioned.
/// This is active iff @a prevIndex is not `-2`.
/// @a prevIndex is used to handle compact emotes. `-1` is used to
/// indicate no predecessor.
///
/// @param element[in] The element to add. This must be non-null and
/// allocated with `new`. Ownership is transferred
/// into this container.
/// @param forceAdd When enabled, @a element will be added regardless of
/// `canAddElements`. If @a element won't be added it will
/// be `delete`d.
/// @param prevIndex Controls the "scenario" (see above). `-2` indicates
/// "regular" mode; other values indicate "repositioning".
/// In case of repositioning, this contains the index of
/// the precesding element (visually, according to
/// @a textDirection_ [RTL/LTR]).
void addElement(MessageLayoutElement *element, bool forceAdd, void addElement(MessageLayoutElement *element, bool forceAdd,
int prevIndex); qsizetype prevIndex);
// this method is called when a message has an RTL word /// @brief Reorders the last line according to @a textDirection_
// we need to reorder the words to be shown properly ///
// however we don't we to reorder non-text elements like badges, timestamps, username /// If a line contains RTL or the text direction is RTL, elements need to be
// firstTextIndex is the index of the first text element that we need to start the reordering from /// reordered (see @a lineContainsRTL_ and @a isRTL respectively).
void reorderRTL(int firstTextIndex); /// This method reverses sequences of text in the opposite direction for it
/// to remain in its intended direction when rendered. Non-text elements
/// won't be reordered.
///
/// For example, in an RTL container, the sequence
/// "1_R 2_R 3_N 4_R 5_L 6_L 7_N 8_R" will be (visually) reordered to
/// "8_R 5_L 6_L 7_N 4_R 3_N 2_R 1_R" (x_{L,N,R} indicates the element with
/// id x which is in the direction {LTR,Neutral,RTL}).
///
/// @param firstTextIndex The index of the first element of the message
/// (i.e. the index after the username).
void reorderRTL(size_t firstTextIndex);
/** /**
* Paint a selection rectangle over the given line * Paint a selection rectangle over the given line
@ -274,6 +311,15 @@ private:
*/ */
bool canCollapse() const; bool canCollapse() const;
/// @returns true, if @a textDirection_ is RTL
[[nodiscard]] bool isRTL() const noexcept;
/// @returns true, if @a textDirection_ is LTR
[[nodiscard]] bool isLTR() const noexcept;
/// @returns true, if @a textDirection_ is Neutral
[[nodiscard]] bool isNeutral() const noexcept;
// variables // variables
float scale_ = 1.F; float scale_ = 1.F;
/** /**
@ -304,13 +350,18 @@ private:
int currentWordId_ = 0; int currentWordId_ = 0;
bool canAddMessages_ = true; bool canAddMessages_ = true;
bool isCollapsed_ = false; bool isCollapsed_ = false;
bool wasPrevReversed_ = false;
/** /// @brief True if the current line contains any RTL text.
* containsRTL indicates whether or not any of the text in this message ///
* contains any right-to-left characters (e.g. arabic) /// If the line contains any RTL, it needs to be reordered after a
*/ /// linebreak after which it's reset to `false`.
bool containsRTL = false; bool lineContainsRTL_ = false;
/// @brief The direction of the text in this container.
///
/// This starts off as neutral until an element is encountered that is
/// either LTR or RTL (afterwards this remains constant).
TextDirection textDirection_ = TextDirection::Neutral;
std::vector<std::unique_ptr<MessageLayoutElement>> elements_; std::vector<std::unique_ptr<MessageLayoutElement>> elements_;
@ -320,6 +371,10 @@ private:
* These lines hold no relation to the elements that are in this * These lines hold no relation to the elements that are in this
*/ */
std::vector<Line> lines_; std::vector<Line> lines_;
#ifdef FRIEND_TEST
FRIEND_TEST(MessageLayoutContainerTest, RtlReordering);
#endif
}; };
} // namespace chatterino } // namespace chatterino

View file

@ -46,6 +46,7 @@ set(test_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/Scrollbar.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Scrollbar.cpp
${CMAKE_CURRENT_LIST_DIR}/src/Commands.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Commands.cpp
${CMAKE_CURRENT_LIST_DIR}/src/FlagsEnum.cpp ${CMAKE_CURRENT_LIST_DIR}/src/FlagsEnum.cpp
${CMAKE_CURRENT_LIST_DIR}/src/MessageLayoutContainer.cpp
# Add your new file above this line! # Add your new file above this line!
) )

View file

@ -0,0 +1,237 @@
#include "messages/layouts/MessageLayoutContainer.hpp"
#include "common/Literals.hpp"
#include "messages/Emote.hpp"
#include "messages/layouts/MessageLayoutElement.hpp"
#include "messages/Message.hpp"
#include "messages/MessageElement.hpp"
#include "mocks/BaseApplication.hpp"
#include "singletons/Fonts.hpp"
#include "singletons/Resources.hpp"
#include "singletons/Theme.hpp"
#include "Test.hpp"
#include <memory>
#include <vector>
using namespace chatterino;
using namespace literals;
namespace {
class MockApplication : mock::BaseApplication
{
public:
MockApplication()
: theme(this->paths_)
, fonts(this->settings)
{
}
Theme *getThemes() override
{
return &this->theme;
}
Fonts *getFonts() override
{
return &this->fonts;
}
Theme theme;
Fonts fonts;
};
std::vector<std::shared_ptr<MessageElement>> makeElements(const QString &text)
{
std::vector<std::shared_ptr<MessageElement>> elements;
bool seenUsername = false;
for (const auto &word : text.split(' '))
{
if (word.startsWith('@'))
{
if (seenUsername)
{
elements.emplace_back(std::make_shared<MentionElement>(
word, word, MessageColor{}, MessageColor{}));
}
else
{
elements.emplace_back(std::make_shared<TextElement>(
word, MessageElementFlag::Username, MessageColor{},
FontStyle::ChatMediumBold));
seenUsername = true;
}
continue;
}
if (word.startsWith('!'))
{
auto emote = std::make_shared<Emote>(Emote{
.name = EmoteName{word},
.images = ImageSet{Image::fromResourcePixmap(
getResources().buttons.addSplit)},
.tooltip = {},
.homePage = {},
.id = {},
.author = {},
.baseName = {},
});
elements.emplace_back(std::make_shared<EmoteElement>(
emote, MessageElementFlag::TwitchEmote));
continue;
}
elements.emplace_back(std::make_shared<TextElement>(
word, MessageElementFlag::Text, MessageColor{},
FontStyle::ChatMedium));
}
return elements;
}
using TestParam = std::tuple<QString, QString, TextDirection>;
} // namespace
namespace chatterino {
class MessageLayoutContainerTest : public ::testing::TestWithParam<TestParam>
{
public:
MessageLayoutContainerTest() = default;
MockApplication mockApplication;
};
TEST_P(MessageLayoutContainerTest, RtlReordering)
{
auto [inputText, expected, expectedDirection] = GetParam();
MessageLayoutContainer container;
container.beginLayout(10000, 1.0F, 1.0F, {MessageFlag::Collapsed});
auto elements = makeElements(inputText);
for (const auto &element : elements)
{
element->addToContainer(container, {
MessageElementFlag::Text,
MessageElementFlag::Username,
MessageElementFlag::TwitchEmote,
});
}
container.breakLine();
ASSERT_EQ(container.line_, 1) << "unexpected linebreak";
// message layout elements ordered by x position
std::vector<MessageLayoutElement *> ordered;
ordered.reserve(container.elements_.size());
for (const auto &el : container.elements_)
{
ordered.push_back(el.get());
}
std::ranges::sort(ordered, [](auto *a, auto *b) {
return a->getRect().x() < b->getRect().x();
});
QString got;
for (const auto &el : ordered)
{
if (!got.isNull())
{
got.append(' ');
}
if (dynamic_cast<ImageLayoutElement *>(el))
{
el->addCopyTextToString(got);
ASSERT_TRUE(got.endsWith(' '));
got.chop(1);
}
else
{
got.append(el->getText());
}
}
ASSERT_EQ(got, expected) << got;
ASSERT_EQ(container.textDirection_, expectedDirection) << got;
}
INSTANTIATE_TEST_SUITE_P(
MessageLayoutContainer, MessageLayoutContainerTest,
testing::Values(
TestParam{
u"@aliens foo bar baz @foo qox !emote1 !emote2"_s,
u"@aliens foo bar baz @foo qox !emote1 !emote2"_s,
TextDirection::LTR,
},
TestParam{
u"@aliens ! foo bar baz @foo qox !emote1 !emote2"_s,
u"@aliens ! foo bar baz @foo qox !emote1 !emote2"_s,
TextDirection::LTR,
},
TestParam{
u"@aliens ."_s,
u"@aliens ."_s,
TextDirection::Neutral,
},
// RTL
TestParam{
u"@aliens و غير دارت إعادة, بل كما وقام قُدُماً. قام تم الجوي بوابة, خلاف أراض هو بلا. عن وحتّى ميناء غير"_s,
u"@aliens غير ميناء وحتّى عن بلا. هو أراض خلاف بوابة, الجوي تم قام قُدُماً. وقام كما بل إعادة, دارت غير و"_s,
TextDirection::RTL,
},
TestParam{
u"@aliens و غير دارت إعادة, بل ض هو my LTR 123 بلا. عن 123 456 وحتّى ميناء غير"_s,
u"@aliens غير ميناء وحتّى 456 123 عن بلا. my LTR 123 هو ض بل إعادة, دارت غير و"_s,
TextDirection::RTL,
},
TestParam{
u"@aliens ور دارت إ @user baz bar عاد هو my LTR 123 بلا. عن 123 456 وحتّ غير"_s,
u"@aliens غير وحتّ 456 123 عن بلا. my LTR 123 هو عاد baz bar @user إ دارت ور"_s,
TextDirection::RTL,
},
TestParam{
u"@aliens ور !emote1 !emote2 !emote3 دارت إ @user baz bar عاد هو my LTR 123 بلا. عن 123 456 وحتّ غير"_s,
u"@aliens غير وحتّ 456 123 عن بلا. my LTR 123 هو عاد baz bar @user إ دارت !emote3 !emote2 !emote1 ور"_s,
TextDirection::RTL,
},
TestParam{
u"@aliens ور !emote1 !emote2 LTR text !emote3 !emote4 غير"_s,
u"@aliens غير LTR text !emote3 !emote4 !emote2 !emote1 ور"_s,
TextDirection::RTL,
},
TestParam{
u"@aliens !!! ور !emote1 !emote2 LTR text !emote3 !emote4 غير"_s,
u"@aliens غير LTR text !emote3 !emote4 !emote2 !emote1 ور !!!"_s,
TextDirection::RTL,
},
// LTR
TestParam{
u"@aliens LTR و غير دا ميناء غير"_s,
u"@aliens LTR غير ميناء دا غير و"_s,
TextDirection::LTR,
},
TestParam{
u"@aliens LTR و غير د ض هو my LTR 123 بلا. عن 123 456 وحتّى مير"_s,
u"@aliens LTR هو ض د غير و my LTR 123 مير وحتّى 456 123 عن بلا."_s,
TextDirection::LTR,
},
TestParam{
u"@aliens LTR ور دارت إ @user baz bar عاد هو my LTR 123 بلا. عن 123 456 وحتّ غير"_s,
u"@aliens LTR @user إ دارت ور baz bar هو عاد my LTR 123 غير وحتّ 456 123 عن بلا."_s,
TextDirection::LTR,
},
TestParam{
u"@aliens LTR ور !emote1 !emote2 !emote3 دارت إ @user baz bar عاد هو my LTR 123 بلا. عن 123 456 وحتّ غير"_s,
u"@aliens LTR @user إ دارت !emote3 !emote2 !emote1 ور baz bar هو عاد my LTR 123 غير وحتّ 456 123 عن بلا."_s,
TextDirection::LTR,
},
TestParam{
u"@aliens LTR غير وحتّ !emote1 !emote2 LTR text !emote3 !emote4 عاد هو"_s,
u"@aliens LTR !emote2 !emote1 وحتّ غير LTR text !emote3 !emote4 هو عاد"_s,
TextDirection::LTR,
}));
} // namespace chatterino