diff --git a/CHANGELOG.md b/CHANGELOG.md index e948a12b1..726f3d89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055, #4067, #4077, #3905) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062) +- Major: Added support for Right-to-Left Languages (#3958) - Minor: Allow hiding moderation actions in streamer mode. (#3926) - Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 60b305738..3dfc12077 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -473,6 +473,7 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, // once we encounter an emote or reach the end of the message text. */ QString currentText; + container.first = FirstWord::Neutral; for (Word &word : this->words_) { auto parsedWords = app->emotes->emojis.parse(word.text); diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index f7e3d9c47..b15c979c7 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -8,6 +8,7 @@ #include "singletons/Fonts.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" +#include "util/Helpers.hpp" #include #include @@ -88,7 +89,7 @@ bool MessageLayoutContainer::canAddElements() } void MessageLayoutContainer::_addElement(MessageLayoutElement *element, - bool forceAdd) + bool forceAdd, int prevIndex) { if (!this->canAddElements() && !forceAdd) { @@ -96,15 +97,19 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, return; } + bool isRTLMode = this->first == FirstWord::RTL && prevIndex != -2; + bool isAddingMode = prevIndex == -2; + // This lambda contains the logic for when to step one 'space width' back for compact x emotes - auto shouldRemoveSpaceBetweenEmotes = [this]() -> bool { - if (this->elements_.empty()) + auto shouldRemoveSpaceBetweenEmotes = [this, prevIndex]() -> bool { + if (prevIndex == -1 || this->elements_.empty()) { // No previous element found return false; } - const auto &lastElement = this->elements_.back(); + const auto &lastElement = prevIndex == -2 ? this->elements_.back() + : this->elements_[prevIndex]; if (!lastElement) { @@ -127,6 +132,26 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, return lastElement->getFlags().has(MessageElementFlag::EmoteImages); }; + if (element->getText().isRightToLeft()) + { + this->containsRTL = true; + } + + // check the first non-neutral word to see if we should render RTL or LTR + if (isAddingMode && this->first == FirstWord::Neutral && + element->getFlags().has(MessageElementFlag::Text) && + !element->getFlags().has(MessageElementFlag::RepliedMessage)) + { + if (element->getText().isRightToLeft()) + { + this->first = FirstWord::RTL; + } + else if (!isNeutral(element->getText())) + { + this->first = FirstWord::LTR; + } + } + // top margin if (this->elements_.size() == 0) { @@ -152,7 +177,7 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, bool isZeroWidthEmote = element->getCreator().getFlags().has( MessageElementFlag::ZeroWidthEmote); - if (isZeroWidthEmote) + if (isZeroWidthEmote && !isRTLMode) { xOffset -= element->getRect().width() + this->spaceWidth_; } @@ -171,8 +196,22 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, element->getFlags().hasAny({MessageElementFlag::EmoteImages}) && !isZeroWidthEmote && shouldRemoveSpaceBetweenEmotes()) { - // Move cursor one 'space width' to the left to combine hug the previous emote - this->currentX_ -= this->spaceWidth_; + // Move cursor one 'space width' to the left (right in case of RTL) to combine hug the previous emote + if (isRTLMode) + { + this->currentX_ += this->spaceWidth_; + } + else + { + this->currentX_ -= this->spaceWidth_; + } + } + + if (isRTLMode) + { + // shift by width since we are calculating according to top right in RTL mode + // but setPosition wants top left + xOffset -= element->getRect().width(); } // set move element @@ -183,22 +222,138 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, element->setLine(this->line_); // add element - this->elements_.push_back(std::unique_ptr(element)); + if (isAddingMode) + { + this->elements_.push_back( + std::unique_ptr(element)); + } // set current x if (!isZeroWidthEmote) { - this->currentX_ += element->getRect().width(); + if (isRTLMode) + { + this->currentX_ -= element->getRect().width(); + } + else + { + this->currentX_ += element->getRect().width(); + } } if (element->hasTrailingSpace()) { - this->currentX_ += this->spaceWidth_; + if (isRTLMode) + { + this->currentX_ -= this->spaceWidth_; + } + else + { + this->currentX_ += this->spaceWidth_; + } + } +} + +void MessageLayoutContainer::reorderRTL(int firstTextIndex) +{ + if (this->elements_.empty()) + { + return; + } + + int startIndex = static_cast(this->lineStart_); + int endIndex = static_cast(this->elements_.size()) - 1; + + if (firstTextIndex >= endIndex) + { + return; + } + startIndex = std::max(startIndex, firstTextIndex); + + std::vector correctSequence; + std::stack swappedSequence; + bool wasPrevReversed = false; + + // 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-neautral word is LTR or all wrods 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 an invisible Arabic letter to fix orentation + // 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++) + { + if (isNeutral(this->elements_[i]->getText()) && + ((this->first == FirstWord::RTL && !wasPrevReversed) || + (this->first == FirstWord::LTR && wasPrevReversed))) + { + this->elements_[i]->reversedNeutral = true; + } + if (((this->elements_[i]->getText().isRightToLeft() != + (this->first == FirstWord::RTL)) && + !isNeutral(this->elements_[i]->getText())) || + (isNeutral(this->elements_[i]->getText()) && wasPrevReversed)) + { + swappedSequence.push(i); + wasPrevReversed = true; + } + else + { + while (!swappedSequence.empty()) + { + correctSequence.push_back(swappedSequence.top()); + swappedSequence.pop(); + } + correctSequence.push_back(i); + wasPrevReversed = false; + } + } + while (!swappedSequence.empty()) + { + correctSequence.push_back(swappedSequence.top()); + swappedSequence.pop(); + } + + // render right to left if we are in RTL mode, otherwise LTR + if (this->first == FirstWord::RTL) + { + this->currentX_ = this->elements_[endIndex]->getRect().right(); + } + else + { + this->currentX_ = this->elements_[startIndex]->getRect().left(); + } + // manually do the first call with -1 as previous index + this->_addElement(this->elements_[correctSequence[0]].get(), false, -1); + + for (int i = 1; i < correctSequence.size(); i++) + { + this->_addElement(this->elements_[correctSequence[i]].get(), false, + correctSequence[i - 1]); } } void MessageLayoutContainer::breakLine() { + if (this->containsRTL) + { + for (int i = 0; i < this->elements_.size(); i++) + { + if (this->elements_[i]->getFlags().has( + MessageElementFlag::Username)) + { + this->reorderRTL(i + 1); + break; + } + } + } + int xOffset = 0; if (this->flags_.has(MessageFlag::Centered) && this->elements_.size() > 0) diff --git a/src/messages/layouts/MessageLayoutContainer.hpp b/src/messages/layouts/MessageLayoutContainer.hpp index c990058a6..153e09794 100644 --- a/src/messages/layouts/MessageLayoutContainer.hpp +++ b/src/messages/layouts/MessageLayoutContainer.hpp @@ -15,6 +15,7 @@ class QPainter; namespace chatterino { enum class MessageFlag : int64_t; +enum class FirstWord { Neutral, RTL, LTR }; using MessageFlags = FlagsEnum; struct Margin { @@ -45,6 +46,9 @@ struct Margin { struct MessageLayoutContainer { MessageLayoutContainer() = default; + FirstWord first = FirstWord::Neutral; + bool containsRTL = false; + int getHeight() const; int getWidth() const; float getScale() const; @@ -60,6 +64,11 @@ struct MessageLayoutContainer { void breakLine(); bool atStartOfLine(); bool fitsInLine(int width_); + // 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); MessageLayoutElement *getElementAt(QPoint point); // painting @@ -86,7 +95,18 @@ private: }; // helpers - void _addElement(MessageLayoutElement *element, bool forceAdd = false); + /* + _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. + */ + void _addElement(MessageLayoutElement *element, bool forceAdd = false, + int prevIndex = -2); bool canCollapse(); const Margin margin = {4, 8, 4, 8}; diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index 1aa9a25ca..7d736ede6 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -12,6 +12,12 @@ #include #include +namespace { + +const QChar RTL_MARK(0x200F); + +} // namespace + namespace chatterino { const QRect &MessageLayoutElement::getRect() const @@ -286,14 +292,20 @@ int TextLayoutElement::getSelectionIndexCount() const void TextLayoutElement::paint(QPainter &painter) { auto app = getApp(); + QString text = this->getText(); + if (text.isRightToLeft() || this->reversedNeutral) + { + text.prepend(RTL_MARK); + text.append(RTL_MARK); + } painter.setPen(this->color_); painter.setFont(app->fonts->getFont(this->style_, this->scale_)); painter.drawText( - QRectF(this->getRect().x(), this->getRect().y(), 10000, 10000), - this->getText(), QTextOption(Qt::AlignLeft | Qt::AlignTop)); + QRectF(this->getRect().x(), this->getRect().y(), 10000, 10000), text, + QTextOption(Qt::AlignLeft | Qt::AlignTop)); } void TextLayoutElement::paintAnimated(QPainter &, int) diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index 6684731ad..5dfec7f0c 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -28,6 +28,8 @@ public: MessageLayoutElement(MessageElement &creator_, const QSize &size); virtual ~MessageLayoutElement(); + bool reversedNeutral = false; + const QRect &getRect() const; MessageElement &getCreator() const; void setPosition(QPoint point); diff --git a/src/util/Helpers.cpp b/src/util/Helpers.cpp index 7df39196a..b145d3adb 100644 --- a/src/util/Helpers.cpp +++ b/src/util/Helpers.cpp @@ -4,6 +4,7 @@ #include #include +#include #include namespace chatterino { @@ -123,6 +124,13 @@ bool startsWithOrContains(const QString &str1, const QString &str2, return str1.contains(str2, caseSensitivity); } +bool isNeutral(const QString &s) +{ + static const QRegularExpression re("\\p{L}"); + const QRegularExpressionMatch match = re.match(s); + return !match.hasMatch(); +} + QString generateUuid() { auto uuid = QUuid::createUuid(); diff --git a/src/util/Helpers.hpp b/src/util/Helpers.hpp index 409089ed7..65d874423 100644 --- a/src/util/Helpers.hpp +++ b/src/util/Helpers.hpp @@ -57,6 +57,11 @@ namespace _helpers_internal { bool startsWithOrContains(const QString &str1, const QString &str2, Qt::CaseSensitivity caseSensitivity, bool startsWith); +/** + * @brief isNeutral checks if the string doesn't contain any character in the unicode "letter" category + * i.e. if the string contains only neutral characters. + **/ +bool isNeutral(const QString &s); QString generateUuid(); QString formatRichLink(const QString &url, bool file = false);