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