mirror of
https://github.com/Chatterino/chatterino2.git
synced 2024-11-13 19:49:51 +01:00
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:
parent
fbfa5e0f41
commit
3fcb7e1702
|
@ -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)
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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};
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in a new issue