Implement initial support for RTL languages (#3958)

Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
fix https://github.com/Chatterino/chatterino2/issues/720
This commit is contained in:
mohad12211 2022-11-10 23:36:19 +03:00 committed by GitHub
parent fbfa5e0f41
commit 3fcb7e1702
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 217 additions and 13 deletions

View file

@ -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 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 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 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: Allow hiding moderation actions in streamer mode. (#3926)
- Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Added highlights for `Elevated Messages`. (#4016)
- Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792)

View file

@ -473,6 +473,7 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container,
// once we encounter an emote or reach the end of the message text. */ // once we encounter an emote or reach the end of the message text. */
QString currentText; QString currentText;
container.first = FirstWord::Neutral;
for (Word &word : this->words_) for (Word &word : this->words_)
{ {
auto parsedWords = app->emotes->emojis.parse(word.text); auto parsedWords = app->emotes->emojis.parse(word.text);

View file

@ -8,6 +8,7 @@
#include "singletons/Fonts.hpp" #include "singletons/Fonts.hpp"
#include "singletons/Settings.hpp" #include "singletons/Settings.hpp"
#include "singletons/Theme.hpp" #include "singletons/Theme.hpp"
#include "util/Helpers.hpp"
#include <QDebug> #include <QDebug>
#include <QPainter> #include <QPainter>
@ -88,7 +89,7 @@ bool MessageLayoutContainer::canAddElements()
} }
void MessageLayoutContainer::_addElement(MessageLayoutElement *element, void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
bool forceAdd) bool forceAdd, int prevIndex)
{ {
if (!this->canAddElements() && !forceAdd) if (!this->canAddElements() && !forceAdd)
{ {
@ -96,15 +97,19 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
return; 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 // This lambda contains the logic for when to step one 'space width' back for compact x emotes
auto shouldRemoveSpaceBetweenEmotes = [this]() -> bool { auto shouldRemoveSpaceBetweenEmotes = [this, prevIndex]() -> bool {
if (this->elements_.empty()) if (prevIndex == -1 || this->elements_.empty())
{ {
// No previous element found // No previous element found
return false; return false;
} }
const auto &lastElement = this->elements_.back(); const auto &lastElement = prevIndex == -2 ? this->elements_.back()
: this->elements_[prevIndex];
if (!lastElement) if (!lastElement)
{ {
@ -127,6 +132,26 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
return lastElement->getFlags().has(MessageElementFlag::EmoteImages); 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 // top margin
if (this->elements_.size() == 0) if (this->elements_.size() == 0)
{ {
@ -152,7 +177,7 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
bool isZeroWidthEmote = element->getCreator().getFlags().has( bool isZeroWidthEmote = element->getCreator().getFlags().has(
MessageElementFlag::ZeroWidthEmote); MessageElementFlag::ZeroWidthEmote);
if (isZeroWidthEmote) if (isZeroWidthEmote && !isRTLMode)
{ {
xOffset -= element->getRect().width() + this->spaceWidth_; xOffset -= element->getRect().width() + this->spaceWidth_;
} }
@ -171,9 +196,23 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
element->getFlags().hasAny({MessageElementFlag::EmoteImages}) && element->getFlags().hasAny({MessageElementFlag::EmoteImages}) &&
!isZeroWidthEmote && shouldRemoveSpaceBetweenEmotes()) !isZeroWidthEmote && shouldRemoveSpaceBetweenEmotes())
{ {
// Move cursor one 'space width' to the left to combine hug the previous emote // 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_; 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 // set move element
element->setPosition( element->setPosition(
@ -183,22 +222,138 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element,
element->setLine(this->line_); element->setLine(this->line_);
// add element // 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 // set current x
if (!isZeroWidthEmote) if (!isZeroWidthEmote)
{
if (isRTLMode)
{
this->currentX_ -= element->getRect().width();
}
else
{ {
this->currentX_ += element->getRect().width(); this->currentX_ += element->getRect().width();
} }
}
if (element->hasTrailingSpace()) if (element->hasTrailingSpace())
{
if (isRTLMode)
{
this->currentX_ -= this->spaceWidth_;
}
else
{ {
this->currentX_ += this->spaceWidth_; 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() 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; int xOffset = 0;
if (this->flags_.has(MessageFlag::Centered) && this->elements_.size() > 0) if (this->flags_.has(MessageFlag::Centered) && this->elements_.size() > 0)

View file

@ -15,6 +15,7 @@ class QPainter;
namespace chatterino { namespace chatterino {
enum class MessageFlag : int64_t; enum class MessageFlag : int64_t;
enum class FirstWord { Neutral, RTL, LTR };
using MessageFlags = FlagsEnum<MessageFlag>; using MessageFlags = FlagsEnum<MessageFlag>;
struct Margin { struct Margin {
@ -45,6 +46,9 @@ struct Margin {
struct MessageLayoutContainer { struct MessageLayoutContainer {
MessageLayoutContainer() = default; MessageLayoutContainer() = default;
FirstWord first = FirstWord::Neutral;
bool containsRTL = false;
int getHeight() const; int getHeight() const;
int getWidth() const; int getWidth() const;
float getScale() const; float getScale() const;
@ -60,6 +64,11 @@ struct MessageLayoutContainer {
void breakLine(); void breakLine();
bool atStartOfLine(); bool atStartOfLine();
bool fitsInLine(int width_); 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); MessageLayoutElement *getElementAt(QPoint point);
// painting // painting
@ -86,7 +95,18 @@ private:
}; };
// helpers // 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(); bool canCollapse();
const Margin margin = {4, 8, 4, 8}; const Margin margin = {4, 8, 4, 8};

View file

@ -12,6 +12,12 @@
#include <QPainter> #include <QPainter>
#include <QPainterPath> #include <QPainterPath>
namespace {
const QChar RTL_MARK(0x200F);
} // namespace
namespace chatterino { namespace chatterino {
const QRect &MessageLayoutElement::getRect() const const QRect &MessageLayoutElement::getRect() const
@ -286,14 +292,20 @@ int TextLayoutElement::getSelectionIndexCount() const
void TextLayoutElement::paint(QPainter &painter) void TextLayoutElement::paint(QPainter &painter)
{ {
auto app = getApp(); 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.setPen(this->color_);
painter.setFont(app->fonts->getFont(this->style_, this->scale_)); painter.setFont(app->fonts->getFont(this->style_, this->scale_));
painter.drawText( painter.drawText(
QRectF(this->getRect().x(), this->getRect().y(), 10000, 10000), QRectF(this->getRect().x(), this->getRect().y(), 10000, 10000), text,
this->getText(), QTextOption(Qt::AlignLeft | Qt::AlignTop)); QTextOption(Qt::AlignLeft | Qt::AlignTop));
} }
void TextLayoutElement::paintAnimated(QPainter &, int) void TextLayoutElement::paintAnimated(QPainter &, int)

View file

@ -28,6 +28,8 @@ public:
MessageLayoutElement(MessageElement &creator_, const QSize &size); MessageLayoutElement(MessageElement &creator_, const QSize &size);
virtual ~MessageLayoutElement(); virtual ~MessageLayoutElement();
bool reversedNeutral = false;
const QRect &getRect() const; const QRect &getRect() const;
MessageElement &getCreator() const; MessageElement &getCreator() const;
void setPosition(QPoint point); void setPosition(QPoint point);

View file

@ -4,6 +4,7 @@
#include <QDirIterator> #include <QDirIterator>
#include <QLocale> #include <QLocale>
#include <QRegularExpression>
#include <QUuid> #include <QUuid>
namespace chatterino { namespace chatterino {
@ -123,6 +124,13 @@ bool startsWithOrContains(const QString &str1, const QString &str2,
return str1.contains(str2, caseSensitivity); 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() QString generateUuid()
{ {
auto uuid = QUuid::createUuid(); auto uuid = QUuid::createUuid();

View file

@ -57,6 +57,11 @@ namespace _helpers_internal {
bool startsWithOrContains(const QString &str1, const QString &str2, bool startsWithOrContains(const QString &str1, const QString &str2,
Qt::CaseSensitivity caseSensitivity, bool startsWith); 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 generateUuid();
QString formatRichLink(const QString &url, bool file = false); QString formatRichLink(const QString &url, bool file = false);