Skip to content

Commit

Permalink
Implement initial support for RTL languages (#3958)
Browse files Browse the repository at this point in the history
Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
fix #720
  • Loading branch information
mohad12211 committed Nov 10, 2022
1 parent fbfa5e0 commit 3fcb7e1
Show file tree
Hide file tree
Showing 8 changed files with 217 additions and 13 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/messages/MessageElement.cpp
Expand Up @@ -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);
Expand Down
175 changes: 165 additions & 10 deletions src/messages/layouts/MessageLayoutContainer.cpp
Expand Up @@ -8,6 +8,7 @@
#include "singletons/Fonts.hpp"
#include "singletons/Settings.hpp"
#include "singletons/Theme.hpp"
#include "util/Helpers.hpp"

#include <QDebug>
#include <QPainter>
Expand Down Expand Up @@ -88,23 +89,27 @@ bool MessageLayoutContainer::canAddElements()
}

void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
bool forceAdd)
bool forceAdd, int prevIndex)
{
if (!this->canAddElements() && !forceAdd)
{
delete 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)
{
Expand All @@ -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)
{
Expand All @@ -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_;
}
Expand All @@ -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
Expand All @@ -183,22 +222,138 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
element->setLine(this->line_);

// add element
this->elements_.push_back(std::unique_ptr<MessageLayoutElement>(element));
if (isAddingMode)
{
this->elements_.push_back(
std::unique_ptr<MessageLayoutElement>(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<int>(this->lineStart_);
int endIndex = static_cast<int>(this->elements_.size()) - 1;

if (firstTextIndex >= endIndex)
{
return;
}
startIndex = std::max(startIndex, firstTextIndex);

std::vector<int> correctSequence;
std::stack<int> 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)
Expand Down
22 changes: 21 additions & 1 deletion src/messages/layouts/MessageLayoutContainer.hpp
Expand Up @@ -15,6 +15,7 @@ class QPainter;
namespace chatterino {

enum class MessageFlag : int64_t;
enum class FirstWord { Neutral, RTL, LTR };
using MessageFlags = FlagsEnum<MessageFlag>;

struct Margin {
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -86,7 +95,18 @@ struct MessageLayoutContainer {
};

// 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};
Expand Down
16 changes: 14 additions & 2 deletions src/messages/layouts/MessageLayoutElement.cpp
Expand Up @@ -12,6 +12,12 @@
#include <QPainter>
#include <QPainterPath>

namespace {

const QChar RTL_MARK(0x200F);

} // namespace

namespace chatterino {

const QRect &MessageLayoutElement::getRect() const
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/messages/layouts/MessageLayoutElement.hpp
Expand Up @@ -28,6 +28,8 @@ class MessageLayoutElement : boost::noncopyable
MessageLayoutElement(MessageElement &creator_, const QSize &size);
virtual ~MessageLayoutElement();

bool reversedNeutral = false;

const QRect &getRect() const;
MessageElement &getCreator() const;
void setPosition(QPoint point);
Expand Down
8 changes: 8 additions & 0 deletions src/util/Helpers.cpp
Expand Up @@ -4,6 +4,7 @@

#include <QDirIterator>
#include <QLocale>
#include <QRegularExpression>
#include <QUuid>

namespace chatterino {
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/util/Helpers.hpp
Expand Up @@ -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);
Expand Down

0 comments on commit 3fcb7e1

Please sign in to comment.