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: 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

View file

@ -53,10 +53,10 @@ Frames::Frames(QList<Frame> &&frames)
getApp()->getEmotes()->getGIFTimer().signal.connect([this] {
this->advance();
});
}
auto totalLength = std::accumulate(this->items_.begin(), this->items_.end(),
0UL, [](auto init, auto &&frame) {
auto totalLength =
std::accumulate(this->items_.begin(), this->items_.end(), 0UL,
[](auto init, auto &&frame) {
return init + frame.duration;
});
@ -67,10 +67,13 @@ Frames::Frames(QList<Frame> &&frames)
else
{
this->durationOffset_ = std::min<int>(
int(getApp()->getEmotes()->getGIFTimer().position() % totalLength),
int(getApp()->getEmotes()->getGIFTimer().position() %
totalLength),
60000);
}
this->processOffset();
}
DebugCount::increase("image bytes", 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. */
QString currentText;
container.first = FirstWord::Neutral;
bool firstIteration = true;
for (Word &word : this->words_)
{

View file

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

View file

@ -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 <QDebug>
#include <QMargins>
#include <QPainter>
#include <QVarLengthArray>
#include <optional>
@ -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<int>(this->lineStart_);
int endIndex = static_cast<int>(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<int> correctSequence;
std::stack<int> swappedSequence;
QVarLengthArray<size_t, 32> correctSequence;
// 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
// 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<qsizetype>(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

View file

@ -10,12 +10,21 @@
#include <optional>
#include <vector>
#if __has_include(<gtest/gtest_prod.h>)
# include <gtest/gtest_prod.h>
#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<MessageFlag>;
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<std::unique_ptr<MessageLayoutElement>> elements_;
@ -320,6 +371,10 @@ private:
* These lines hold no relation to the elements that are in this
*/
std::vector<Line> lines_;
#ifdef FRIEND_TEST
FRIEND_TEST(MessageLayoutContainerTest, RtlReordering);
#endif
};
} // namespace chatterino

View file

@ -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!
)

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