diff --git a/CHANGELOG.md b/CHANGELOG.md index 131f20168..714681d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ - 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: `FlagsEnum` is now `constexpr`. (#5510) +- Dev: Documented and added tests to RTL handling. (#5473) ## 2.5.1 diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index f74bba164..59be19957 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -53,24 +53,27 @@ Frames::Frames(QList &&frames) getApp()->getEmotes()->getGIFTimer().signal.connect([this] { 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(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(getApp()->getEmotes()->getGIFTimer().position() % totalLength), - 60000); - } - this->processOffset(); DebugCount::increase("image bytes", this->memoryUsage()); DebugCount::increase("image bytes (ever loaded)", this->memoryUsage()); } diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 8d994ef22..a8beed9c0 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -593,8 +593,6 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, // once we encounter an emote or reach the end of the message text. */ QString currentText; - container.first = FirstWord::Neutral; - bool firstIteration = true; for (Word &word : this->words_) { diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index 01958ddf2..12a8b153a 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -119,9 +119,9 @@ private: QPixmap *ensureBuffer(QPainter &painter, int width); // variables - MessagePtr message_; + const MessagePtr message_; MessageLayoutContainer container_; - std::unique_ptr buffer_{}; + std::unique_ptr buffer_; bool bufferValid_ = false; int height_ = 0; diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index a4abee58d..3c2bd122d 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -1,4 +1,4 @@ -#include "MessageLayoutContainer.hpp" +#include "messages/layouts/MessageLayoutContainer.hpp" #include "Application.hpp" #include "messages/layouts/MessageLayoutContext.hpp" @@ -14,6 +14,7 @@ #include #include #include +#include #include @@ -55,7 +56,6 @@ void MessageLayoutContainer::beginLayout(int width, float scale, this->currentWordId_ = 0; this->canAddMessages_ = true; this->isCollapsed_ = false; - this->wasPrevReversed_ = false; } void MessageLayoutContainer::endLayout() @@ -71,7 +71,7 @@ void MessageLayoutContainer::endLayout() QSize(this->dotdotdotWidth_, this->textLineHeight_), QColor("#00D80A"), FontStyle::ChatMediumBold, this->scale_); - if (this->first == FirstWord::RTL) + if (this->isRTL()) { // Shift all elements in the next line to the left for (auto i = this->lines_.back().startIndex; @@ -125,9 +125,9 @@ void MessageLayoutContainer::addElementNoLineBreak( 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( MessageElementFlag::Username)) @@ -136,6 +136,7 @@ void MessageLayoutContainer::breakLine() break; } } + this->lineContainsRTL_ = false; } int xOffset = 0; @@ -404,7 +405,7 @@ size_t MessageLayoutContainer::getSelectionIndex(QPoint point) const size_t index = 0; - for (auto i = 0; i < lineEnd; i++) + for (size_t i = 0; i < lineEnd; i++) { auto &&element = this->elements_[i]; @@ -565,30 +566,37 @@ int MessageLayoutContainer::nextWordId() void MessageLayoutContainer::addElement(MessageLayoutElement *element, const bool forceAdd, - const int prevIndex) + const qsizetype prevIndex) { if (!this->canAddElements() && !forceAdd) { + assert(prevIndex == -2 && + "element is still referenced in this->elements_"); delete element; return; } - bool isRTLMode = this->first == FirstWord::RTL && 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 - auto shouldRemoveSpaceBetweenEmotes = [this, prevIndex]() -> bool { + /// Returns `true` if a previously added `spaceWidth_` should be removed + /// 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()) { // No previous element found return false; } - const auto &lastElement = prevIndex == -2 ? this->elements_.back() - : this->elements_[prevIndex]; + const auto &lastElement = + isAddingMode ? this->elements_.back() : this->elements_[prevIndex]; if (!lastElement) { + assert(false && "Empty element in container found"); return false; } @@ -608,23 +616,24 @@ void MessageLayoutContainer::addElement(MessageLayoutElement *element, 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 - if (isAddingMode && this->first == FirstWord::Neutral && + if (isAddingMode && this->isNeutral() && element->getFlags().has(MessageElementFlag::Text) && !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()) { // 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_; } @@ -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 // but setPosition wants top left @@ -697,7 +706,7 @@ void MessageLayoutContainer::addElement(MessageLayoutElement *element, } // set current x - if (isRTLMode) + if (isRTLAdjusting) { this->currentX_ -= element->getRect().width(); } @@ -708,7 +717,7 @@ void MessageLayoutContainer::addElement(MessageLayoutElement *element, if (element->hasTrailingSpace()) { - if (isRTLMode) + if (isRTLAdjusting) { 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()) { return; } - int startIndex = static_cast(this->lineStart_); - int endIndex = static_cast(this->elements_.size()) - 1; + size_t startIndex = this->lineStart_; + size_t endIndex = this->elements_.size() - 1; if (firstTextIndex >= endIndex || startIndex >= this->elements_.size()) { @@ -735,64 +744,53 @@ void MessageLayoutContainer::reorderRTL(int firstTextIndex) } startIndex = std::max(startIndex, firstTextIndex); - std::vector correctSequence; - std::stack swappedSequence; + QVarLengthArray correctSequence; + // temporary buffer to store elements in opposite order + QVarLengthArray swappedSequence; - // we reverse a sequence of words if it's opposite to the text direction - // the second condition below covers the possible three cases: - // 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++) + bool isReversing = false; + for (size_t i = startIndex; i <= endIndex; i++) { auto &element = this->elements_[i]; - const auto neutral = isNeutral(element->getText()); + const auto neutral = chatterino::isNeutral(element->getText()); const auto neutralOrUsername = 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 && - ((this->first == FirstWord::RTL && !this->wasPrevReversed_) || - (this->first == FirstWord::LTR && this->wasPrevReversed_))) + ((this->isRTL() && !isReversing) || (this->isLTR() && isReversing))) { element->reversedNeutral = true; } - if (((element->getText().isRightToLeft() != - (this->first == FirstWord::RTL)) && + + if ((element->getText().isRightToLeft() != this->isRTL() && !neutralOrUsername) || - (neutralOrUsername && this->wasPrevReversed_)) + (neutralOrUsername && isReversing)) { - swappedSequence.push(i); - this->wasPrevReversed_ = true; + swappedSequence.append(i); + isReversing = true; } else { while (!swappedSequence.empty()) { - correctSequence.push_back(swappedSequence.top()); - swappedSequence.pop(); + correctSequence.push_back(swappedSequence.last()); + swappedSequence.pop_back(); } correctSequence.push_back(i); - this->wasPrevReversed_ = false; + isReversing = false; } } while (!swappedSequence.empty()) { - correctSequence.push_back(swappedSequence.top()); - swappedSequence.pop(); + correctSequence.push_back(swappedSequence.last()); + swappedSequence.pop_back(); } // 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(); } @@ -806,10 +804,11 @@ void MessageLayoutContainer::reorderRTL(int firstTextIndex) 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, - correctSequence[i - 1]); + static_cast(correctSequence[i - 1])); } } @@ -992,4 +991,19 @@ bool MessageLayoutContainer::canCollapse() const 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 diff --git a/src/messages/layouts/MessageLayoutContainer.hpp b/src/messages/layouts/MessageLayoutContainer.hpp index dde3f4d45..7cbcaf9fb 100644 --- a/src/messages/layouts/MessageLayoutContainer.hpp +++ b/src/messages/layouts/MessageLayoutContainer.hpp @@ -10,12 +10,21 @@ #include #include +#if __has_include() +# include +#endif + class QPainter; namespace chatterino { +enum class TextDirection : uint8_t { + Neutral, + RTL, + LTR, +}; + enum class MessageFlag : int64_t; -enum class FirstWord { Neutral, RTL, LTR }; using MessageFlags = FlagsEnum; class MessageLayoutElement; struct Selection; @@ -24,8 +33,6 @@ struct MessagePaintContext; struct MessageLayoutContainer { MessageLayoutContainer() = default; - FirstWord first = FirstWord::Neutral; - /** * Begin the layout process of this message * @@ -212,24 +219,54 @@ private: QRect rect; }; - /* - 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 - 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. - 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 - index of the reordered previous element. - In stage one we don't need that and we pass -2 to indicate stage one (i.e. adding mode) - In stage two, we pass -1 for the first element, and the index of the oredered privous element for the rest. - */ + /// @brief Attempts to add @a element to this container + /// + /// This can be called in two scenarios. + /// + /// 1. **Regular**: In this scenario, @a element is positioned and added + /// to the internal container. + /// This is active iff @a prevIndex is `-2`. + /// During this stage, if there isn't any @a textDirection_ detected yet, + /// 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, - int prevIndex); + qsizetype prevIndex); - // this method is called when a message has an RTL word - // we need to reorder the words to be shown properly - // however we don't we to reorder non-text elements like badges, timestamps, username - // firstTextIndex is the index of the first text element that we need to start the reordering from - void reorderRTL(int firstTextIndex); + /// @brief Reorders the last line according to @a textDirection_ + /// + /// If a line contains RTL or the text direction is RTL, elements need to be + /// reordered (see @a lineContainsRTL_ and @a isRTL respectively). + /// 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 @@ -274,6 +311,15 @@ private: */ 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 float scale_ = 1.F; /** @@ -304,13 +350,18 @@ private: int currentWordId_ = 0; bool canAddMessages_ = true; bool isCollapsed_ = false; - bool wasPrevReversed_ = false; - /** - * containsRTL indicates whether or not any of the text in this message - * contains any right-to-left characters (e.g. arabic) - */ - bool containsRTL = false; + /// @brief True if the current line contains any RTL text. + /// + /// If the line contains any RTL, it needs to be reordered after a + /// linebreak after which it's reset to `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> elements_; @@ -320,6 +371,10 @@ private: * These lines hold no relation to the elements that are in this */ std::vector lines_; + +#ifdef FRIEND_TEST + FRIEND_TEST(MessageLayoutContainerTest, RtlReordering); +#endif }; } // namespace chatterino diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 79c490fa9..9f79b77eb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -46,6 +46,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/Scrollbar.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Commands.cpp ${CMAKE_CURRENT_LIST_DIR}/src/FlagsEnum.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/MessageLayoutContainer.cpp # Add your new file above this line! ) diff --git a/tests/src/MessageLayoutContainer.cpp b/tests/src/MessageLayoutContainer.cpp new file mode 100644 index 000000000..b72934c76 --- /dev/null +++ b/tests/src/MessageLayoutContainer.cpp @@ -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 +#include + +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> makeElements(const QString &text) +{ + std::vector> elements; + bool seenUsername = false; + for (const auto &word : text.split(' ')) + { + if (word.startsWith('@')) + { + if (seenUsername) + { + elements.emplace_back(std::make_shared( + word, word, MessageColor{}, MessageColor{})); + } + else + { + elements.emplace_back(std::make_shared( + word, MessageElementFlag::Username, MessageColor{}, + FontStyle::ChatMediumBold)); + seenUsername = true; + } + continue; + } + + if (word.startsWith('!')) + { + auto emote = std::make_shared(Emote{ + .name = EmoteName{word}, + .images = ImageSet{Image::fromResourcePixmap( + getResources().buttons.addSplit)}, + .tooltip = {}, + .homePage = {}, + .id = {}, + .author = {}, + .baseName = {}, + }); + elements.emplace_back(std::make_shared( + emote, MessageElementFlag::TwitchEmote)); + continue; + } + + elements.emplace_back(std::make_shared( + word, MessageElementFlag::Text, MessageColor{}, + FontStyle::ChatMedium)); + } + + return elements; +} + +using TestParam = std::tuple; + +} // namespace + +namespace chatterino { + +class MessageLayoutContainerTest : public ::testing::TestWithParam +{ +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 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(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