lib_ui/ui/widgets/fields/input_field.cpp

6213 lines
175 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// This file is part of Desktop App Toolkit,
// a set of libraries for developing nice desktop applications.
//
// For license and copyright information please follow this link:
// https://github.com/desktop-app/legal/blob/master/LEGAL
//
#include "ui/widgets/fields/input_field.h"
#include "base/platform/base_platform_info.h"
#include "base/qt_signal_producer.h"
#include "base/qt/qt_common_adapters.h"
#include "base/invoke_queued.h"
#include "base/qthelp_regex.h"
#include "base/random.h"
#include "ui/platform/ui_platform_utility.h"
#include "emoji_suggestions_helper.h"
#include "ui/text/text.h"
#include "ui/text/text_html_tags.h"
#include "ui/basic_click_handlers.h"
#include "ui/text/text_renderer.h" // kQuoteCollapsedLines
#include "ui/widgets/fields/custom_field_object.h"
#include "ui/widgets/labels.h"
#include "ui/widgets/popup_menu.h"
#include "ui/emoji_config.h"
#include "ui/ui_utility.h"
#include "ui/painter.h"
#include "ui/qt_object_factory.h"
#include "ui/integration.h"
#include "styles/style_widgets.h"
#include "styles/palette.h"
#include <QtCore/QMimeData>
#include <QtCore/QRegularExpression>
#include <QtGui/QClipboard>
#include <QtGui/QTextBlock>
#include <QtGui/QTextDocumentFragment>
#include <QtGui/QRawFont>
#include <QtWidgets/QApplication>
#include <QtWidgets/QCommonStyle>
#include <QtWidgets/QScrollBar>
#include <QtWidgets/QTextEdit>
#include <QShortcut>
#include <private/qkeymapper_p.h>
#include <crl/crl_async.h>
namespace Ui {
namespace {
constexpr auto kInstantReplaceRandomId = QTextFormat::UserProperty;
constexpr auto kInstantReplaceWhatId = QTextFormat::UserProperty + 1;
constexpr auto kInstantReplaceWithId = QTextFormat::UserProperty + 2;
constexpr auto kReplaceTagId = QTextFormat::UserProperty + 3;
constexpr auto kTagProperty = QTextFormat::UserProperty + 4;
constexpr auto kCustomEmojiText = QTextFormat::UserProperty + 5;
constexpr auto kCustomEmojiLink = QTextFormat::UserProperty + 6;
constexpr auto kCustomEmojiId = QTextFormat::UserProperty + 7;
constexpr auto kQuoteFormatId = QTextFormat::UserProperty + 8;
constexpr auto kQuoteId = QTextFormat::UserProperty + 9;
constexpr auto kPreLanguage = QTextFormat::UserProperty + 10;
constexpr auto kCollapsedQuoteFormat = QTextFormat::UserObject + 1;
constexpr auto kCustomEmojiFormat = QTextFormat::UserObject + 2;
constexpr auto kObjectReplacementCh = QChar(QChar::ObjectReplacementCharacter);
const auto kObjectReplacement = QString::fromRawData(
&kObjectReplacementCh,
1);
const auto &kTagBold = InputField::kTagBold;
const auto &kTagItalic = InputField::kTagItalic;
const auto &kTagUnderline = InputField::kTagUnderline;
const auto &kTagStrikeOut = InputField::kTagStrikeOut;
const auto &kTagCode = InputField::kTagCode;
const auto &kTagPre = InputField::kTagPre;
const auto &kTagBlockquote = InputField::kTagBlockquote;
const auto &kTagBlockquoteCollapsed = InputField::kTagBlockquoteCollapsed;
const auto &kTagSpoiler = InputField::kTagSpoiler;
const auto kTagCheckLinkMeta = u"^:/:/:^"_q;
const auto kSoftLine = QChar::LineSeparator;
const auto kHardLine = QChar::ParagraphSeparator;
// We need unique tags otherwise same custom emoji would join in a single
// QTextCharFormat with the same properties, including kCustomEmojiText.
auto GlobalCustomEmojiCounter = 0;
class InputDocument : public QTextDocument {
public:
InputDocument(QObject *parent, const style::InputField &st);
protected:
QVariant loadResource(int type, const QUrl &name) override;
private:
const style::InputField &_st;
std::map<QUrl, QVariant> _emojiCache;
rpl::lifetime _lifetime;
};
InputDocument::InputDocument(QObject *parent, const style::InputField &st)
: QTextDocument(parent)
, _st(st) {
Emoji::Updated(
) | rpl::on_next([=] {
_emojiCache.clear();
}, _lifetime);
}
QVariant InputDocument::loadResource(int type, const QUrl &name) {
if (type != QTextDocument::ImageResource
|| name.scheme() != qstr("emoji")) {
return QTextDocument::loadResource(type, name);
}
const auto i = _emojiCache.find(name);
if (i != _emojiCache.end()) {
return i->second;
}
auto result = [&] {
if (const auto emoji = Emoji::FromUrl(name.toDisplayString())) {
const auto height = std::max(
_st.style.font->height * style::DevicePixelRatio(),
Emoji::GetSizeNormal());
return QVariant(Emoji::SinglePixmap(emoji, height));
}
return QVariant();
}();
_emojiCache.emplace(name, result);
return result;
}
[[nodiscard]] bool IsNewline(QChar ch) {
return (ch == '\r')
|| (ch == '\n')
|| (ch == QChar(0xfdd0)) // QTextBeginningOfFrame
|| (ch == QChar(0xfdd1)) // QTextEndOfFrame
|| (ch == QChar(QChar::ParagraphSeparator))
|| (ch == QChar(QChar::LineSeparator));
}
[[nodiscard]] bool IsTagPre(QStringView tag) {
return tag.startsWith(kTagPre);
}
[[nodiscard]] bool IsBlockTag(QStringView tag) {
return (tag == kTagBlockquote)
|| (tag == kTagBlockquoteCollapsed)
|| IsTagPre(tag);
}
[[nodiscard]] bool IsUsernameChar(QChar ch) {
const auto code = ch.unicode();
return (code >= 'a' && code <= 'z')
|| (code >= 'A' && code <= 'Z')
|| (code >= '0' && code <= '9')
|| (ch == '_');
}
[[nodiscard]] QStringView TrimmedView(QStringView text) {
auto from = 0;
auto till = text.size();
while (from != till && text[from].isSpace()) {
++from;
}
while (till != from && text[till - 1].isSpace()) {
--till;
}
return text.mid(from, till - from);
}
[[nodiscard]] QString StartingMention(QStringView text) {
const auto trimmed = TrimmedView(text);
if (trimmed.size() < 3 || trimmed[0] != '@') {
return QString();
}
auto till = 1;
while (till != trimmed.size() && IsUsernameChar(trimmed[till])) {
++till;
}
return (till > 1 && till != trimmed.size())
? trimmed.mid(0, till).toString()
: QString();
}
[[nodiscard]] bool HtmlTextMatchesPlainTextStart(
const QString &htmlText,
const QString &plainText) {
const auto htmlMention = StartingMention(QStringView(htmlText));
return htmlMention.isEmpty()
|| StartingMention(QStringView(plainText)) == htmlMention;
}
// Drops every formatting tag-part whose occurrences across all tags together
// cover the entire parsed text. Coverage is judged per formatting-part, not
// per individual tag: a part is whole-spanning when the union of the ranges
// of every tag containing it equals [0, text.size()) with no gaps, even when
// inner formatting fragmented that part across several adjacent tags. Such a
// part is removed from the id of every tag that contains it; tags whose id
// becomes empty are erased and the remainder is re-simplified.
void TrimFullCoverageTags(TextWithTags &parsed) {
const auto full = int(parsed.text.size());
if (!full || parsed.tags.isEmpty()) {
return;
}
auto parts = std::vector<QString>();
for (const auto &tag : parsed.tags) {
for (const auto &part : TextUtilities::SplitTags(tag.id)) {
auto already = false;
for (const auto &existing : parts) {
if (QStringView(existing) == part) {
already = true;
break;
}
}
if (!already) {
parts.push_back(part.toString());
}
}
}
const auto coversFull = [&](const QString &part) {
auto spans = std::vector<std::pair<int, int>>();
for (const auto &tag : parsed.tags) {
if (TextUtilities::SplitTags(tag.id).contains(QStringView(part))) {
spans.emplace_back(tag.offset, tag.offset + tag.length);
}
}
if (spans.empty()) {
return false;
}
std::sort(spans.begin(), spans.end());
if (spans.front().first != 0) {
return false;
}
auto covered = 0;
for (const auto &[from, till] : spans) {
if (from > covered) {
return false;
}
covered = std::max(covered, till);
}
return (covered >= full);
};
auto wholeSpan = std::vector<QString>();
for (const auto &part : parts) {
if (coversFull(part)) {
wholeSpan.push_back(part);
}
}
if (wholeSpan.empty()) {
return;
}
for (auto i = parsed.tags.begin(); i != parsed.tags.end();) {
auto id = i->id;
for (const auto &part : wholeSpan) {
id = TextUtilities::TagWithRemoved(id, part);
}
if (id.isEmpty()) {
i = parsed.tags.erase(i);
} else {
i->id = id;
++i;
}
}
parsed.tags = TextUtilities::SimplifyTags(std::move(parsed.tags));
}
// Detects Ctrl+Shift+V (or any "Paste shortcut with extra Shift") in a way
// that survives non-Latin keyboard layouts. QKeyEvent::matches() only looks
// at the layout-translated key(), so on Russian etc. the V key reports as
// Cyrillic М and stripping Shift is not enough. QKeyMapper::possibleKeys()
// returns the Latin fallback as one of the alternatives, which is exactly
// what QShortcutMap uses for plain Ctrl+V to keep working across layouts.
[[nodiscard]] bool IsPasteWithShift(not_null<QKeyEvent*> e) {
if (!(e->modifiers() & Qt::ShiftModifier)) {
return false;
}
const auto bindings = QKeySequence::keyBindings(QKeySequence::Paste);
if (bindings.empty()) {
return false;
}
const auto match = [&](Qt::KeyboardModifiers mods, int key) {
if (!(mods & Qt::ShiftModifier)) {
return false;
}
const auto combined = (int(mods & ~Qt::ShiftModifier) | key)
& ~int(Qt::KeypadModifier | Qt::GroupSwitchModifier);
const auto sequence = QKeySequence(combined);
for (const auto &binding : bindings) {
if (binding == sequence) {
return true;
}
}
return false;
};
if (match(e->modifiers(), e->key())) {
return true;
}
const auto possible = QKeyMapper::possibleKeys(e);
for (const auto &p : possible) {
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
if (match(p.keyboardModifiers(), int(p.key()))) {
return true;
}
#else // Qt >= 6.7.0
const auto mods = Qt::KeyboardModifiers(
p & Qt::KeyboardModifierMask);
const auto key = p & ~int(Qt::KeyboardModifierMask);
if (match(mods, key)) {
return true;
}
#endif // Qt < 6.7.0
}
return false;
}
[[nodiscard]] QStringView FindBlockTag(QStringView tag) {
for (const auto &tag : TextUtilities::SplitTags(tag)) {
if (IsBlockTag(tag)) {
return tag;
}
}
return QStringView();
}
[[nodiscard]] bool HasBlockTag(QStringView tag) {
return !FindBlockTag(tag).isEmpty();
}
[[nodiscard]] bool HasBlockTag(const QTextBlock &block) {
return HasBlockTag(
block.blockFormat().property(kQuoteFormatId).toString());
}
[[nodiscard]] bool HasSpoilerTag(QStringView tag) {
return ranges::contains(TextUtilities::SplitTags(tag), kTagSpoiler);
}
[[nodiscard]] bool StartsWithPre(not_null<QTextDocument*> document) {
const auto format = document->firstBlock().blockFormat();
const auto tag = format.property(kQuoteFormatId).toString();
return IsTagPre(FindBlockTag(tag));
}
[[nodiscard]] QString WithBlockTagRemoved(QStringView tag) {
auto list = TextUtilities::SplitTags(tag);
list.erase(ranges::remove_if(list, IsBlockTag), list.end());
return list.isEmpty() ? QString() : TextUtilities::JoinTag(list);
}
[[nodiscard]] QTextBlock FindBlock(
not_null<QTextDocument*> document,
int id) {
auto block = document->firstBlock();
for (; block.isValid(); block = block.next()) {
if (block.blockFormat().property(kQuoteId).toInt() == id) {
return block;
}
}
return QTextBlock();
}
[[nodiscard]] TextWithTags ShiftLeftBlockTag(TextWithTags text) {
while (!text.tags.isEmpty() && text.tags.front().length <= 0) {
text.tags.erase(text.tags.begin());
}
if (text.tags.isEmpty()
|| text.empty()
|| !IsNewline(text.text.front())) {
return text;
}
auto &tag = text.tags.front();
if (tag.offset > 0 || !HasBlockTag(tag.id)) {
return text;
}
auto stripped = WithBlockTagRemoved(tag.id);
if (tag.length == 1) {
if (stripped.isEmpty()) {
text.tags.erase(text.tags.begin());
} else {
tag.id = stripped;
}
} else {
++tag.offset;
--tag.length;
if (!stripped.isEmpty()) {
text.tags.insert(
text.tags.begin(),
TextWithTags::Tag{ 0, 1, stripped });
}
}
return text;
}
[[nodiscard]] TextWithTags ShiftRightBlockTag(TextWithTags text) {
while (!text.tags.isEmpty() && text.tags.back().length <= 0) {
text.tags.pop_back();
}
if (text.tags.isEmpty()
|| text.empty()
|| !IsNewline(text.text.back())) {
return text;
}
auto &tag = text.tags.back();
if (tag.offset + tag.length < text.text.size() || !HasBlockTag(tag.id)) {
return text;
}
auto stripped = WithBlockTagRemoved(tag.id);
if (tag.length == 1) {
if (stripped.isEmpty()) {
text.tags.pop_back();
} else {
tag.id = stripped;
}
} else {
--tag.length;
if (!stripped.isEmpty()) {
text.tags.push_back({ int(text.text.size()) - 1, 1, stripped });
}
}
return text;
}
[[nodiscard]] bool IsValidMarkdownLink(QStringView link) {
return (link.indexOf('.') >= 0) || (link.indexOf(':') >= 0);
}
[[nodiscard]] bool IsCustomEmojiLink(QStringView link) {
return link.startsWith(InputField::kCustomEmojiTagStart);
}
[[nodiscard]] bool IsCustomDateLink(QStringView link) {
return link.startsWith(InputField::kCustomDateTagStart);
}
[[nodiscard]] bool IsEditableLinkTag(
QStringView tag,
bool instantViewEditorTagsEnabled) {
return InputField::IsValidMarkdownLink(tag)
|| InputField::IsCustomDateLink(tag)
|| (instantViewEditorTagsEnabled
&& InputField::IsInstantViewAnchorLink(tag));
}
[[nodiscard]] QString TagWithoutInstantViewMath(QStringView tag) {
return TextUtilities::TagWithRemoved(
tag.toString(),
InputField::kTagIvMath);
}
[[nodiscard]] QString TagWithAddedDroppingMath(
const QString &tag,
const QString &added,
bool instantViewEditorTagsEnabled) {
if (instantViewEditorTagsEnabled
&& added == InputField::kTagIvMath) {
return InputField::kTagIvMath;
}
const auto base = (instantViewEditorTagsEnabled
&& added != InputField::kTagIvMath)
? TagWithoutInstantViewMath(tag)
: tag;
return TextUtilities::TagWithAdded(base, added);
}
[[nodiscard]] QString MakeUniqueCustomEmojiLink(QStringView link) {
if (!IsCustomEmojiLink(link)) {
return link.toString();
}
const auto index = link.indexOf('?');
return u"%1?%2"_q
.arg((index < 0) ? link : base::StringViewMid(link, 0, index))
.arg(++GlobalCustomEmojiCounter);
}
[[nodiscard]] QString DefaultTagMimeProcessor(QStringView mimeTag) {
// By default drop formatting in InputField-s.
return {};
}
[[nodiscard]] uint64 CustomEmojiIdFromLink(QStringView link) {
const auto skip = InputField::kCustomEmojiTagStart.size();
const auto index = link.indexOf('?', skip + 1);
return base::StringViewMid(
link,
skip,
(index <= skip) ? -1 : (index - skip)
).toULongLong();
}
[[nodiscard]] QString CheckFullTextTag(
const TextWithTags &textWithTags,
const QString &tag,
bool instantViewEditorTagsEnabled = false) {
auto resultLink = QString();
const auto checkingLink = (tag == kTagCheckLinkMeta);
const auto &text = textWithTags.text;
auto from = 0;
auto till = int(text.size());
const auto adjust = [&] {
for (; from != till; ++from) {
if (!IsNewline(text[from]) && !Text::IsSpace(text[from])) {
break;
}
}
};
for (const auto &existing : textWithTags.tags) {
adjust();
if (existing.offset > from) {
return QString();
}
auto found = false;
for (const auto &single : TextUtilities::SplitTags(existing.id)) {
if (checkingLink
&& IsEditableLinkTag(single, instantViewEditorTagsEnabled)) {
if (resultLink.isEmpty()) {
resultLink = single.toString();
found = true;
break;
} else if (QStringView(resultLink) == single) {
found = true;
break;
}
return QString();
} else if (!checkingLink) {
const auto matches = [&] {
if (tag == kTagPre) {
return IsTagPre(single);
}
const auto normalized = IsTagPre(single)
? QStringView(kTagCode)
: single;
return QStringView(tag) == normalized;
}();
if (matches) {
found = true;
break;
}
}
}
if (!found) {
return QString();
}
from = std::clamp(existing.offset + existing.length, from, till);
}
while (till != from) {
if (!IsNewline(text[till - 1]) && !Text::IsSpace(text[till - 1])) {
break;
}
--till;
}
return (from < till) ? QString() : checkingLink ? resultLink : tag;
}
[[nodiscard]] bool HasFullTextTag(
const TextWithTags &textWithTags,
const QString &tag) {
return !CheckFullTextTag(textWithTags, tag).isEmpty();
}
[[nodiscard]] QString ReadPreLanguageName(
const QString &text,
int preStart,
int preLength) {
auto view = QStringView(text).mid(preStart, preLength);
static const auto expression = QRegularExpression(
"^([a-zA-Z0-9\\+\\-]+)[\\r\\n]");
const auto m = expression.match(view);
return m.hasMatch() ? m.captured(1).toLower() : QString();
}
class RangeAccumulator {
public:
explicit RangeAccumulator(std::vector<InputFieldTextRange> &ranges)
: _ranges(ranges) {
}
~RangeAccumulator() {
finish();
}
void add(int offset, int length) {
if (_count > 0 && _ranges[_count - 1].till >= offset) {
accumulate_max(_ranges[_count - 1].till, offset + length);
return;
}
if (_count == _ranges.size()) {
_ranges.push_back({ offset, offset + length });
} else {
_ranges[_count] = { offset, offset + length };
}
++_count;
}
void finish() {
if (_count < _ranges.size()) {
_ranges.resize(_count);
}
}
private:
std::vector<InputFieldTextRange> &_ranges;
int _count = 0;
};
class TagAccumulator {
public:
explicit TagAccumulator(TextWithTags::Tags &tags) : _tags(tags) {
}
[[nodiscard]] bool changed() const {
return _changed;
}
[[nodiscard]] QString currentTag() const {
return _currentTagId;
}
void feed(const QString &randomTagId, int currentPosition) {
if (randomTagId == _currentTagId) {
return;
}
if (!_currentTagId.isEmpty()) {
const auto tag = TextWithTags::Tag{
_currentStart,
currentPosition - _currentStart,
_currentTagId
};
if (tag.length > 0) {
if (_currentTag >= _tags.size()) {
_changed = true;
_tags.push_back(tag);
} else if (_tags[_currentTag] != tag) {
_changed = true;
_tags[_currentTag] = tag;
}
++_currentTag;
}
}
_currentTagId = randomTagId;
_currentStart = currentPosition;
}
void finish() {
if (_currentTag < _tags.size()) {
_tags.resize(_currentTag);
_changed = true;
}
}
private:
TextWithTags::Tags &_tags;
bool _changed = false;
int _currentTag = 0;
int _currentStart = 0;
QString _currentTagId;
};
struct TagStartExpression {
QString tag;
QString goodBefore;
QString badAfter;
QString badBefore;
QString goodAfter;
};
constexpr auto kTagBoldIndex = 0;
constexpr auto kTagItalicIndex = 1;
//constexpr auto kTagUnderlineIndex = 2;
constexpr auto kTagStrikeOutIndex = 2;
constexpr auto kTagCodeIndex = 3;
constexpr auto kTagPreIndex = 4;
constexpr auto kTagSpoilerIndex = 5;
constexpr auto kInvalidPosition = std::numeric_limits<int>::max() / 2;
class TagSearchItem {
public:
enum class Edge {
Open,
Close,
};
int matchPosition(Edge edge) const {
return (_position >= 0) ? _position : kInvalidPosition;
}
void applyOffset(int offset) {
if (_position < offset) {
_position = -1;
}
accumulate_max(_offset, offset);
}
void fill(
const QString &text,
Edge edge,
const TagStartExpression &expression) {
const auto length = text.size();
const auto &tag = expression.tag;
const auto tagLength = tag.size();
const auto isGoodBefore = [&](QChar ch) {
return expression.goodBefore.isEmpty()
|| (expression.goodBefore.indexOf(ch) >= 0);
};
const auto isBadAfter = [&](QChar ch) {
return !expression.badAfter.isEmpty()
&& (expression.badAfter.indexOf(ch) >= 0);
};
const auto isBadBefore = [&](QChar ch) {
return !expression.badBefore.isEmpty()
&& (expression.badBefore.indexOf(ch) >= 0);
};
const auto isGoodAfter = [&](QChar ch) {
return expression.goodAfter.isEmpty()
|| (expression.goodAfter.indexOf(ch) >= 0);
};
const auto check = [&](Edge edge) {
if (_position > 0) {
const auto before = text[_position - 1];
if (tag == kTagPre && before != '\n' && before != '\r') {
return false;
}
if ((edge == Edge::Open && !isGoodBefore(before))
|| (edge == Edge::Close && isBadBefore(before))) {
return false;
}
}
if (_position + tagLength < length) {
const auto after = text[_position + tagLength];
if ((edge == Edge::Open && isBadAfter(after))
|| (edge == Edge::Close && !isGoodAfter(after))) {
return false;
}
}
return true;
};
const auto edgeIndex = static_cast<int>(edge);
if (_position >= 0) {
if (_checked[edgeIndex]) {
return;
} else if (check(edge)) {
_checked[edgeIndex] = true;
return;
} else {
_checked = { { false, false } };
}
}
while (true) {
_position = text.indexOf(tag, _offset);
if (_position < 0) {
_offset = _position = kInvalidPosition;
break;
}
_offset = _position + tagLength;
if (check(edge)) {
break;
} else {
continue;
}
}
if (_position == kInvalidPosition) {
_checked = { { true, true } };
} else {
_checked = { { false, false } };
_checked[edgeIndex] = true;
}
}
private:
int _offset = 0;
int _position = -1;
std::array<bool, 2> _checked = { { false, false } };
};
const std::vector<TagStartExpression> &TagStartExpressions() {
static auto cached = std::vector<TagStartExpression> {
{
kTagBold,
TextUtilities::MarkdownBoldGoodBefore(),
TextUtilities::MarkdownBoldBadAfter(),
TextUtilities::MarkdownBoldBadAfter(),
TextUtilities::MarkdownBoldGoodBefore()
},
{
kTagItalic,
TextUtilities::MarkdownItalicGoodBefore(),
TextUtilities::MarkdownItalicBadAfter(),
TextUtilities::MarkdownItalicBadAfter(),
TextUtilities::MarkdownItalicGoodBefore()
},
//{
// kTagUnderline,
// TextUtilities::MarkdownUnderlineGoodBefore(),
// TextUtilities::MarkdownUnderlineBadAfter(),
// TextUtilities::MarkdownUnderlineBadAfter(),
// TextUtilities::MarkdownUnderlineGoodBefore()
//},
{
kTagStrikeOut,
TextUtilities::MarkdownStrikeOutGoodBefore(),
TextUtilities::MarkdownStrikeOutBadAfter(),
TextUtilities::MarkdownStrikeOutBadAfter(),
QString(),
},
{
kTagCode,
TextUtilities::MarkdownCodeGoodBefore(),
TextUtilities::MarkdownCodeBadAfter(),
TextUtilities::MarkdownCodeBadAfter(),
TextUtilities::MarkdownCodeGoodBefore()
},
{
kTagPre,
TextUtilities::MarkdownPreGoodBefore(),
TextUtilities::MarkdownPreBadAfter(),
TextUtilities::MarkdownPreBadAfter(),
TextUtilities::MarkdownPreGoodBefore()
},
{
kTagSpoiler,
TextUtilities::MarkdownSpoilerGoodBefore(),
TextUtilities::MarkdownSpoilerBadAfter(),
TextUtilities::MarkdownSpoilerBadAfter(),
TextUtilities::MarkdownSpoilerGoodBefore()
},
};
return cached;
}
const std::map<QString, int> &TagIndices() {
static auto cached = std::map<QString, int> {
{ kTagBold, kTagBoldIndex },
{ kTagItalic, kTagItalicIndex },
//{ kTagUnderline, kTagUnderlineIndex },
{ kTagStrikeOut, kTagStrikeOutIndex },
{ kTagCode, kTagCodeIndex },
{ kTagPre, kTagPreIndex },
{ kTagSpoiler, kTagSpoilerIndex },
};
return cached;
}
bool DoesTagFinishByNewline(const QString &tag) {
return (tag == kTagCode);
}
class MarkdownTagAccumulator {
public:
using Edge = TagSearchItem::Edge;
MarkdownTagAccumulator(std::vector<InputField::MarkdownTag> *tags)
: _tags(tags)
, _expressions(TagStartExpressions())
, _tagIndices(TagIndices())
, _items(_expressions.size()) {
}
// Here we use the fact that text either contains only emoji
// { adjustedTextLength = text.size() * (emojiLength - 1) }
// or contains no emoji at all and can have tag edges in the middle
// { adjustedTextLength = 0 }.
//
// Otherwise we would have to pass emoji positions inside text.
void feed(
const QString &text,
int adjustedTextLength,
const QString &textTag) {
if (!_tags) {
return;
}
const auto guard = gsl::finally([&] {
_currentInternalLength += text.size();
_currentAdjustedLength += adjustedTextLength;
});
if (!textTag.isEmpty()) {
finishTags();
return;
}
for (auto &item : _items) {
item = TagSearchItem();
}
auto tryFinishTag = _currentTag;
while (true) {
for (; tryFinishTag != _currentFreeTag; ++tryFinishTag) {
auto &tag = (*_tags)[tryFinishTag];
if (tag.internalLength >= 0) {
continue;
}
const auto i = _tagIndices.find(tag.tag);
Assert(i != end(_tagIndices));
const auto tagIndex = i->second;
const auto atLeastOffset =
tag.internalStart
+ tag.tag.size()
+ 1
- _currentInternalLength;
_items[tagIndex].applyOffset(atLeastOffset);
fillItem(
tagIndex,
text,
Edge::Close);
if (finishByNewline(tryFinishTag, text, tagIndex)) {
continue;
}
const auto position = matchPosition(tagIndex, Edge::Close);
if (position < kInvalidPosition) {
const auto till = position + tag.tag.size();
finishTag(tryFinishTag, till, true);
_items[tagIndex].applyOffset(till);
}
}
for (auto i = 0, count = int(_items.size()); i != count; ++i) {
fillItem(i, text, Edge::Open);
}
const auto min = minIndex(Edge::Open);
if (min < 0) {
return;
}
startTag(matchPosition(min, Edge::Open), _expressions[min].tag);
}
}
void finish() {
if (!_tags) {
return;
}
finishTags();
if (_currentTag < _tags->size()) {
_tags->resize(_currentTag);
}
}
private:
void finishTag(int index, int offsetFromAccumulated, bool closed) {
Expects(_tags != nullptr);
Expects(index >= 0 && index < _tags->size());
auto &tag = (*_tags)[index];
if (tag.internalLength < 0) {
tag.internalLength = _currentInternalLength
+ offsetFromAccumulated
- tag.internalStart;
tag.adjustedLength = _currentAdjustedLength
+ offsetFromAccumulated
- tag.adjustedStart;
tag.closed = closed;
}
if (index == _currentTag) {
++_currentTag;
}
}
bool finishByNewline(
int index,
const QString &text,
int tagIndex) {
Expects(_tags != nullptr);
Expects(index >= 0 && index < _tags->size());
auto &tag = (*_tags)[index];
if (!DoesTagFinishByNewline(tag.tag)) {
return false;
}
const auto endPosition = newlinePosition(
text,
std::max(0, tag.internalStart + 1 - _currentInternalLength));
if (matchPosition(tagIndex, Edge::Close) <= endPosition) {
return false;
}
finishTag(index, endPosition, false);
return true;
}
void finishTags() {
while (_currentTag != _currentFreeTag) {
finishTag(_currentTag, 0, false);
}
}
void startTag(int offsetFromAccumulated, const QString &tag) {
Expects(_tags != nullptr);
const auto newTag = InputField::MarkdownTag{
_currentInternalLength + offsetFromAccumulated,
-1,
_currentAdjustedLength + offsetFromAccumulated,
-1,
false,
tag
};
if (_currentFreeTag < _tags->size()) {
(*_tags)[_currentFreeTag] = newTag;
} else {
_tags->push_back(newTag);
}
++_currentFreeTag;
}
void fillItem(int index, const QString &text, Edge edge) {
Expects(index >= 0 && index < _items.size());
_items[index].fill(text, edge, _expressions[index]);
}
int matchPosition(int index, Edge edge) const {
Expects(index >= 0 && index < _items.size());
return _items[index].matchPosition(edge);
}
int newlinePosition(const QString &text, int offset) const {
const auto length = text.size();
if (offset < length) {
const auto begin = text.data();
const auto end = begin + length;
for (auto ch = begin + offset; ch != end; ++ch) {
if (IsNewline(*ch)) {
return (ch - begin);
}
}
}
return kInvalidPosition;
}
int minIndex(Edge edge) const {
auto result = -1;
auto minPosition = kInvalidPosition;
for (auto i = 0, count = int(_items.size()); i != count; ++i) {
const auto position = matchPosition(i, edge);
if (position < minPosition) {
minPosition = position;
result = i;
}
}
return result;
}
int minIndexForFinish(const std::vector<int> &indices) const {
const auto tagIndex = indices[0];
auto result = -1;
auto minPosition = kInvalidPosition;
for (auto i : indices) {
const auto edge = (i == tagIndex) ? Edge::Close : Edge::Open;
const auto position = matchPosition(i, edge);
if (position < minPosition) {
minPosition = position;
result = i;
}
}
return result;
}
std::vector<InputField::MarkdownTag> *_tags = nullptr;
const std::vector<TagStartExpression> &_expressions;
const std::map<QString, int> &_tagIndices;
std::vector<TagSearchItem> _items;
int _currentTag = 0;
int _currentFreeTag = 0;
int _currentInternalLength = 0;
int _currentAdjustedLength = 0;
};
template <typename Iterator>
QString AccumulateText(Iterator begin, Iterator end) {
auto result = QString();
result.reserve(end - begin);
for (auto i = end; i != begin;) {
result.push_back(*--i);
}
return result;
}
QTextImageFormat PrepareEmojiFormat(EmojiPtr emoji, int emojiHeight) {
const auto factor = style::DevicePixelRatio();
const auto size = std::max(emojiHeight * factor, Emoji::GetSizeNormal());
const auto width = Emoji::GetSizeNormal() + st::emojiPadding * factor * 2;
auto result = QTextImageFormat();
result.setWidth(width / factor);
result.setHeight(size / factor);
result.setName(emoji->toUrl());
result.setVerticalAlignment(QTextCharFormat::AlignTop);
return result;
}
QTextImageFormat PrepareEmojiFormat(EmojiPtr emoji, style::font font) {
return PrepareEmojiFormat(emoji, font->height);
}
[[nodiscard]] QTextCharFormat PrepareTagFormat(
const style::InputField &st,
QStringView tag,
bool instantViewEditorTagsEnabled) {
auto result = QTextCharFormat();
auto font = st.style.font;
auto color = std::optional<QColor>();
auto bg = std::optional<QColor>();
auto vertical = QTextCharFormat::AlignNormal;
auto replaceWhat = QString();
auto replaceWith = QString();
const auto applyOne = [&](QStringView tag) {
if (IsCustomEmojiLink(tag)) {
replaceWhat = tag.toString();
replaceWith = MakeUniqueCustomEmojiLink(tag);
result.setObjectType(kCustomEmojiFormat);
result.setProperty(kCustomEmojiLink, replaceWith);
result.setProperty(
kCustomEmojiId,
CustomEmojiIdFromLink(replaceWith));
vertical = QTextCharFormat::AlignTop;
} else if (IsValidMarkdownLink(tag)) {
color = st::defaultTextPalette.linkFg->c;
} else if (tag == kTagBold) {
font = font->bold();
} else if (tag == kTagItalic) {
font = font->italic();
} else if (tag == kTagUnderline) {
font = font->underline();
} else if (tag == kTagStrikeOut) {
font = font->strikeout();
} else if (tag == kTagCode || IsTagPre(tag)) {
color = st::defaultTextPalette.monoFg->c;
font = font->monospace();
} else if (instantViewEditorTagsEnabled) {
if (tag == InputField::kTagIvMarked) {
bg = st.textMarkBg->c;
} else if (tag == InputField::kTagIvSubscript) {
vertical = QTextCharFormat::AlignSubScript;
} else if (tag == InputField::kTagIvSuperscript) {
vertical = QTextCharFormat::AlignSuperScript;
} else if (tag == InputField::kTagIvMath) {
color = st::defaultTextPalette.monoFg->c;
} else if (InputField::IsInstantViewAnchorLink(tag)) {
color = st::defaultTextPalette.linkFg->c;
}
}
};
auto tags = TextUtilities::SplitTags(tag);
if (!instantViewEditorTagsEnabled) {
tags.erase(ranges::remove_if(tags, [](QStringView tag) {
return InputField::IsInstantViewEditorTag(tag);
}), tags.end());
}
for (const auto &tag : tags) {
applyOne(tag);
}
const auto filteredTag = TextUtilities::JoinTag(tags);
const auto script = (vertical == QTextCharFormat::AlignSubScript)
|| (vertical == QTextCharFormat::AlignSuperScript);
if (script) {
auto adjusted = QFont(font);
const auto target = font->suborsuper()->f;
if (const auto pixels = target.pixelSize(); pixels > 0) {
adjusted.setPixelSize(int(base::SafeRound(pixels * 3. / 2.)));
} else if (const auto points = target.pointSize(); points > 0) {
adjusted.setPointSize(int(base::SafeRound(points * 3. / 2.)));
}
result.setFont(adjusted);
} else {
result.setFont(font);
}
result.setVerticalAlignment(vertical);
result.setForeground(color.value_or(st.textFg->c));
auto value = filteredTag;
result.setProperty(
kTagProperty,
(replaceWhat.isEmpty()
? value
: value.replace(replaceWhat, replaceWith)));
if (bg) {
result.setBackground(*bg);
} else {
result.setBackground(QBrush());
}
if (result.objectType() == kCustomEmojiFormat) {
result.setVerticalAlignment(QTextCharFormat::AlignTop);
}
return result;
}
[[nodiscard]] int CollapsedQuoteCutoff(const style::InputField &st) {
return (Text::kQuoteCollapsedLines + 0.8) * st.style.font->height;
}
[[nodiscard]] int BlockLineHeight(const style::InputField &st) {
return (st.style.lineHeight > 0)
? std::max(st.style.lineHeight, st.style.font->height)
: st.style.font->height;
}
[[nodiscard]] float64 QTextEditLineAscent(
const style::font &font,
int lineHeight) {
const auto fixedLineHeight = float64(std::max(lineHeight, font->height));
const auto leading = std::max(font->fleading.toReal(), 0.);
return std::clamp(
(fixedLineHeight * 4 / 5) - leading,
0.,
fixedLineHeight);
}
[[nodiscard]] Qt::Alignment HorizontalTextAlign(style::align align) {
const auto horizontal = align
& (Qt::AlignLeft
| Qt::AlignRight
| Qt::AlignHCenter
| Qt::AlignJustify);
return (horizontal != 0) ? horizontal : Qt::AlignLeft;
}
void SetBlockMargins(QTextBlockFormat &format, const style::QuoteStyle &st) {
format.setLeftMargin(st.padding.left());
format.setTopMargin(st.padding.top()
+ st.header
+ st.verticalSkip
+ st.verticalSkip // Those are overlapping margins, not paddings :(
+ st.padding.bottom());
format.setRightMargin(st.padding.right());
format.setBottomMargin(st.padding.bottom()
+ st.verticalSkip
+ st.verticalSkip // Those are overlapping margins, not paddings :(
+ st.padding.top());
}
[[nodiscard]] QRect ExtendForPaint(QRect rect, const style::QuoteStyle &st) {
return rect.marginsAdded(
st.padding + QMargins(0, st.header, 0, 0)
).translated(st.padding.left(), 0);
}
[[nodiscard]] QTextBlockFormat PrepareBlockFormat(
const style::InputField &st,
QStringView tag = {},
int quoteId = -1) {
static auto AutoincrementId = 0;
auto result = QTextBlockFormat();
if (tag != kTagBlockquoteCollapsed) {
result.setLineHeight(
BlockLineHeight(st),
QTextBlockFormat::FixedHeight);
}
result.setAlignment(HorizontalTextAlign(st.textAlign));
const auto id = (quoteId < 0) ? ++AutoincrementId : quoteId;
if (tag == kTagBlockquote || tag == kTagBlockquoteCollapsed) {
result.setProperty(kQuoteFormatId, tag.toString());
result.setProperty(kQuoteId, id);
SetBlockMargins(result, st.style.blockquote);
} else if (IsTagPre(tag)) {
result.setProperty(kQuoteFormatId, tag.toString());
result.setProperty(kQuoteId, id);
result.setProperty(kPreLanguage, tag.mid(kTagPre.size()).toString());
SetBlockMargins(result, st.style.pre);
}
return result;
}
void RemoveDocumentTags(
const style::InputField &st,
not_null<QTextDocument*> document,
int from,
int end) {
auto cursor = QTextCursor(document);
auto blocksCheckedTill = from;
while (blocksCheckedTill < end) {
auto block = document->findBlock(blocksCheckedTill);
const auto till = block.position() + block.length();
if (from <= block.position() && till <= end + 1) {
const auto format = block.blockFormat();
const auto id = format.property(kQuoteFormatId).toString();
if (!id.isEmpty()) {
cursor.setPosition(blocksCheckedTill);
cursor.setBlockFormat(PrepareBlockFormat(st));
}
}
blocksCheckedTill = till;
}
cursor.setPosition(from);
cursor.setPosition(end, QTextCursor::KeepAnchor);
auto format = QTextCharFormat();
format.setProperty(kTagProperty, QString());
format.setProperty(kReplaceTagId, QString());
format.setForeground(st.textFg);
format.setBackground(QBrush());
format.setFont(st.style.font);
format.setVerticalAlignment(QTextCharFormat::AlignNormal);
cursor.mergeCharFormat(format);
}
[[nodiscard]] QString TagWithoutCustomEmoji(QStringView tag) {
auto tags = TextUtilities::SplitTags(tag);
for (auto i = tags.begin(); i != tags.end();) {
if (IsCustomEmojiLink(*i)) {
i = tags.erase(i);
} else {
++i;
}
}
return TextUtilities::JoinTag(tags);
}
void RemoveCustomEmojiTag(
const style::InputField &st,
not_null<QTextDocument*> document,
const QString &existingTags,
bool instantViewEditorTagsEnabled,
int from,
int end) {
auto cursor = QTextCursor(document);
cursor.setPosition(from);
cursor.setPosition(end, QTextCursor::KeepAnchor);
auto format = PrepareTagFormat(
st,
TagWithoutCustomEmoji(existingTags),
instantViewEditorTagsEnabled);
format.setProperty(kCustomEmojiLink, QString());
format.setProperty(kCustomEmojiId, QString());
cursor.mergeCharFormat(format);
}
void ApplyTagFormat(QTextCharFormat &to, const QTextCharFormat &from) {
if (from.hasProperty(kTagProperty)) {
to.setProperty(
kTagProperty,
TagWithoutCustomEmoji(from.property(kTagProperty).toString()));
}
to.setProperty(kReplaceTagId, from.property(kReplaceTagId));
to.setFont(from.font());
to.setVerticalAlignment(from.verticalAlignment());
if (from.hasProperty(QTextFormat::ForegroundBrush)) {
to.setForeground(from.brushProperty(QTextFormat::ForegroundBrush));
}
if (from.hasProperty(QTextFormat::BackgroundBrush)) {
to.setBackground(from.brushProperty(QTextFormat::BackgroundBrush));
}
}
[[nodiscard]] bool IsCollapsedQuoteFragment(const QTextFragment &fragment) {
return (fragment.charFormat().objectType() == kCollapsedQuoteFormat)
&& (fragment.text() == kObjectReplacement);
}
[[nodiscard]] int FindCollapsedQuoteObject(const QTextBlock &block) {
for (auto fragmentIt = block.begin(); !fragmentIt.atEnd(); ++fragmentIt) {
auto fragment = fragmentIt.fragment();
if (IsCollapsedQuoteFragment(fragment)) {
return fragment.position();
}
}
return -1;
}
[[nodiscard]] TextWithTags PrepareForInsert(TextWithTags data) {
auto &text = data.text;
auto length = int(data.text.size());
const auto newline = [&](int position) {
return IsNewline(text.at(position));
};
const auto force = [&](int position, QChar ch) {
if (text.at(position) != ch) {
// Don't detach unnecessary.
text[position] = ch;
}
};
for (auto i = 0, length = int(data.text.size()); i != length; ++i) {
if (newline(i)) {
force(i, kSoftLine);
}
}
for (auto i = data.tags.begin(); i != data.tags.end();) {
auto &tagStart = *i;
const auto &id = tagStart.id;
auto from = std::min(tagStart.offset, length);
auto till = std::min(tagStart.offset + tagStart.length, length);
if (from >= till) {
i = data.tags.erase(i);
continue;
}
const auto block = FindBlockTag(id);
if (block.isEmpty()) {
++i;
continue;
}
auto j = i + 1;
for (; j != data.tags.end(); ++j) {
auto &next = *j;
if (next.offset > till || FindBlockTag(next.id) != block) {
break;
}
till = std::min(next.offset + next.length, length);
}
auto &tagEnd = *(j - 1);
if (from > 0 && newline(from - 1)) {
force(from - 1, kHardLine);
} else if (newline(from)) {
force(from, kHardLine);
++from;
++tagStart.offset;
} else if (from > 0) {
data.text.insert(from, kHardLine);
++length;
for (auto &tag : data.tags) {
if (tag.offset >= from) {
++tag.offset;
} else if (tag.offset + tag.length > from) {
++tag.length;
}
}
++from;
++till;
}
if (till < length && newline(till)) {
force(till, kHardLine);
} else if (newline(till - 1)) {
force(till - 1, kHardLine);
--till;
--tagEnd.length;
} else if (till < length) {
data.text.insert(till, kHardLine);
++length;
for (auto &tag : data.tags) {
if (tag.offset >= till) {
++tag.offset;
} else if (tag.offset + tag.length > till) {
++tag.length;
}
}
}
i = j;
}
return data;
}
[[nodiscard]] QString FullTag(
const QTextCharFormat &ch,
const QTextBlockFormat &block) {
auto simple = WithBlockTagRemoved(ch.property(kTagProperty).toString());
auto quote = block.property(kQuoteFormatId).toString();
return quote.isEmpty()
? simple
: simple.isEmpty()
? quote
: TextUtilities::TagWithAdded(simple, quote);
}
[[nodiscard]] TextWithTags WrapInQuote(
TextWithTags text,
const QString &blockTag) {
auto from = 0, till = int(text.text.size());
for (auto i = text.tags.begin(); from < till;) {
if (i == text.tags.end()) {
if (from < till) {
text.tags.push_back({ from, till - from, blockTag });
}
break;
} else if (i->offset > from) {
i = text.tags.insert(
i,
{ from, i->offset - from, blockTag });
++i;
}
i->id = TextUtilities::TagWithAdded(i->id, blockTag);
from = i->offset + i->length;
++i;
}
return text;
}
// Returns the position of the first inserted tag or "changedEnd" value if none found.
int ProcessInsertedTags(
const style::InputField &st,
not_null<QTextDocument*> document,
int changedPosition,
int changedEnd,
const TextWithTags::Tags &tags,
bool tagsReplaceExisting,
bool instantViewEditorTagsEnabled,
Fn<QString(QStringView)> processor) {
auto firstTagStart = changedEnd;
auto applyNoTagFrom = tagsReplaceExisting ? changedPosition : changedEnd;
for (const auto &tag : tags) {
int tagFrom = changedPosition + tag.offset;
int tagTo = tagFrom + tag.length;
accumulate_max(tagFrom, changedPosition);
accumulate_min(tagTo, changedEnd);
auto tagId = processor ? processor(tag.id) : tag.id;
if (tagTo > tagFrom && !tagId.isEmpty()) {
accumulate_min(firstTagStart, tagFrom);
PrepareFormattingOptimization(document);
if (applyNoTagFrom < tagFrom) {
RemoveDocumentTags(
st,
document,
applyNoTagFrom,
tagFrom);
}
QTextCursor c(document);
c.setPosition(tagFrom);
c.setPosition(tagTo, QTextCursor::KeepAnchor);
if (const auto block = FindBlockTag(tag.id); !block.isEmpty()) {
c.setBlockFormat(PrepareBlockFormat(st, block));
} else if (tagsReplaceExisting) {
const auto block = c.block();
const auto blockStart = block.position();
if (blockStart >= changedPosition
&& blockStart + block.length() - 1 <= changedEnd) {
c.setBlockFormat(PrepareBlockFormat(st));
}
}
c.mergeCharFormat(PrepareTagFormat(
st,
tagId,
instantViewEditorTagsEnabled));
applyNoTagFrom = tagTo;
}
}
if (applyNoTagFrom < changedEnd) {
RemoveDocumentTags(st, document, applyNoTagFrom, changedEnd);
}
return firstTagStart;
}
// When inserting a part of text inside a tag we need to have
// a way to know if the insertion replaced the end of the tag
// or it was strictly inside (in the middle) of the tag.
bool WasInsertTillTheEndOfTag(
QTextBlock block,
QTextBlock::iterator fragmentIt,
int insertionEnd) {
const auto format = fragmentIt.fragment().charFormat();
const auto insertTagName = format.property(kTagProperty);
while (true) {
for (; !fragmentIt.atEnd(); ++fragmentIt) {
const auto fragment = fragmentIt.fragment();
const auto position = fragment.position();
const auto outsideInsertion = (position >= insertionEnd);
if (outsideInsertion) {
const auto format = fragment.charFormat();
const auto tag = format.property(kTagProperty).toString();
return TagWithoutCustomEmoji(tag)
!= TagWithoutCustomEmoji(insertTagName.toString());
}
const auto end = position + fragment.length();
const auto notFullFragmentInserted = (end > insertionEnd);
if (notFullFragmentInserted) {
return false;
}
}
block = block.next();
if (block.isValid()) {
fragmentIt = block.begin();
} else {
break;
}
}
// Insertion goes till the end of the text => not strictly inside a tag.
return true;
}
struct FormattingAction {
enum class Type {
Invalid,
InsertEmoji,
InsertCustomEmoji,
RemoveCustomEmoji,
RemoveTag,
RemoveNewline,
ClearInstantReplace,
FixLineHeight,
FixPreTag,
CollapseBlockquote,
CutCollapsedBefore,
CutCollapsedAfter,
MakeCollapsedBlockquote,
RemoveBlockquote,
};
Type type = Type::Invalid;
EmojiPtr emoji = nullptr;
QString existingTags;
QString customEmojiText;
QString customEmojiLink;
int intervalStart = 0;
int intervalEnd = 0;
int quoteId = 0;
};
} // namespace
// kTagUnderline is not used for Markdown.
const QString InputField::kTagBold = u"**"_q;
const QString InputField::kTagItalic = u"__"_q;
const QString InputField::kTagUnderline = u"^^"_q;
const QString InputField::kTagStrikeOut = u"~~"_q;
const QString InputField::kTagCode = u"`"_q;
const QString InputField::kTagPre = u"```"_q;
const QString InputField::kTagSpoiler = u"||"_q;
const QString InputField::kTagBlockquote = u">"_q;
const QString InputField::kTagBlockquoteCollapsed = u">^"_q;
const QString InputField::kTagIvMarked = u"iv-marked"_q;
const QString InputField::kTagIvSubscript = u"iv-subscript"_q;
const QString InputField::kTagIvSuperscript = u"iv-superscript"_q;
const QString InputField::kTagIvMath = u"iv-math"_q;
const QString InputField::kCustomEmojiTagStart = u"custom-emoji://"_q;
const QString InputField::kCustomDateTagStart = u"custom-date://"_q;
const int InputField::kCollapsedQuoteFormat = ::Ui::kCollapsedQuoteFormat;
const int InputField::kCustomEmojiFormat = ::Ui::kCustomEmojiFormat;
const int InputField::kCustomEmojiId = ::Ui::kCustomEmojiId;
const int InputField::kCustomEmojiLink = ::Ui::kCustomEmojiLink;
const int InputField::kQuoteId = ::Ui::kQuoteId;
class InputField::Inner final : public QTextEdit {
public:
Inner(not_null<InputField*> parent) : QTextEdit(parent) {
}
protected:
bool viewportEvent(QEvent *e) override {
return outer()->viewportEventInner(e);
}
void focusInEvent(QFocusEvent *e) override {
return outer()->focusInEventInner(e);
}
void focusOutEvent(QFocusEvent *e) override {
return outer()->focusOutEventInner(e);
}
void keyPressEvent(QKeyEvent *e) override {
return outer()->keyPressEventInner(e);
}
void contextMenuEvent(QContextMenuEvent *e) override {
return outer()->contextMenuEventInner(e);
}
void dropEvent(QDropEvent *e) override {
return outer()->dropEventInner(e);
}
void inputMethodEvent(QInputMethodEvent *e) override {
return outer()->inputMethodEventInner(e);
}
void paintEvent(QPaintEvent *e) override {
return outer()->paintEventInner(e);
}
void mousePressEvent(QMouseEvent *e) override {
return outer()->mousePressEventInner(e);
}
void mouseReleaseEvent(QMouseEvent *e) override {
return outer()->mouseReleaseEventInner(e);
}
void mouseMoveEvent(QMouseEvent *e) override {
return outer()->mouseMoveEventInner(e);
}
void leaveEvent(QEvent *e) override {
return outer()->leaveEventInner(e);
}
bool canInsertFromMimeData(const QMimeData *source) const override {
return outer()->canInsertFromMimeDataInner(source);
}
void insertFromMimeData(const QMimeData *source) override {
return outer()->insertFromMimeDataInner(source);
}
QMimeData *createMimeDataFromSelection() const override {
return outer()->createMimeDataFromSelectionInner();
}
private:
not_null<InputField*> outer() const {
return static_cast<InputField*>(parentWidget());
}
friend class InputField;
};
void InsertEmojiAtCursor(QTextCursor cursor, EmojiPtr emoji) {
const auto currentFormat = cursor.charFormat();
const auto height = QFontMetrics(currentFormat.font()).height();
auto format = PrepareEmojiFormat(emoji, height);
ApplyTagFormat(format, currentFormat);
format.setVerticalAlignment(QTextCharFormat::AlignTop);
cursor.insertText(kObjectReplacement, format);
}
void InsertCustomEmojiAtCursor(
not_null<InputField*> field,
QTextCursor cursor,
const QString &text,
const QString &link) {
const auto currentFormat = cursor.charFormat();
const auto unique = MakeUniqueCustomEmojiLink(link);
auto format = QTextCharFormat();
format.setObjectType(kCustomEmojiFormat);
format.setProperty(kCustomEmojiText, text);
format.setProperty(kCustomEmojiLink, unique);
format.setProperty(kCustomEmojiId, CustomEmojiIdFromLink(link));
format.setVerticalAlignment(QTextCharFormat::AlignTop);
format.setFont(field->st().style.font);
format.setForeground(field->st().textFg);
format.setBackground(QBrush());
ApplyTagFormat(format, currentFormat);
format.setVerticalAlignment(QTextCharFormat::AlignTop);
format.setProperty(kTagProperty, TagWithAddedDroppingMath(
format.property(kTagProperty).toString(),
unique,
true));
cursor.insertText(kObjectReplacement, format);
}
[[nodiscard]] QTextCharFormat PrepareCollapsedQuoteFormat(int quoteId) {
auto result = QTextCharFormat();
result.setObjectType(kCollapsedQuoteFormat);
result.setProperty(kTagProperty, kTagBlockquoteCollapsed);
result.setProperty(kQuoteId, quoteId);
result.setVerticalAlignment(QTextCharFormat::AlignNormal);
return result;
}
void InstantReplaces::add(const QString &what, const QString &with) {
auto node = &reverseMap;
for (auto i = what.end(), b = what.begin(); i != b;) {
node = &node->tail.emplace(*--i, Node()).first->second;
}
node->text = with;
accumulate_max(maxLength, int(what.size()));
}
const InstantReplaces &InstantReplaces::Default() {
static const auto result = [] {
auto result = InstantReplaces();
result.add("--", QString(1, QChar(8212)));
result.add("<<", QString(1, QChar(171)));
result.add(">>", QString(1, QChar(187)));
result.add(
":shrug:",
QChar(175) + QString("\\_(") + QChar(12484) + ")_/" + QChar(175));
result.add(":o ", QString(1, QChar(0xD83D)) + QChar(0xDE28));
result.add("xD ", QString(1, QChar(0xD83D)) + QChar(0xDE06));
const auto &replacements = Emoji::internal::GetAllReplacements();
for (const auto &one : replacements) {
const auto with = Emoji::QStringFromUTF16(one.emoji);
const auto what = Emoji::QStringFromUTF16(one.replacement);
result.add(what, with);
}
const auto &pairs = Emoji::internal::GetReplacementPairs();
for (const auto &[what, index] : pairs) {
const auto emoji = Emoji::internal::ByIndex(index);
Assert(emoji != nullptr);
result.add(what, emoji->text());
}
return result;
}();
return result;
}
const InstantReplaces &InstantReplaces::TextOnly() {
static const auto result = [] {
auto result = InstantReplaces();
result.add("--", QString(1, QChar(8212)));
result.add("<<", QString(1, QChar(171)));
result.add(">>", QString(1, QChar(187)));
result.add(
":shrug:",
QChar(175) + QString("\\_(") + QChar(12484) + ")_/" + QChar(175));
return result;
}();
return result;
}
bool MarkdownEnabledState::disabled() const {
return v::is<MarkdownDisabled>(data);
}
bool MarkdownEnabledState::enabledForTag(QStringView tag) const {
const auto yes = std::get_if<MarkdownEnabled>(&data);
return yes
&& (yes->tagsSubset.empty() || yes->tagsSubset.contains(tag));
}
InputField::InputField(
QWidget *parent,
const style::InputField &st,
rpl::producer<QString> placeholder,
const QString &value)
: InputField(
parent,
st,
Mode::SingleLine,
std::move(placeholder),
{ value, {} }) {
}
InputField::InputField(
QWidget *parent,
const style::InputField &st,
Mode mode,
rpl::producer<QString> placeholder,
const QString &value)
: InputField(
parent,
st,
mode,
std::move(placeholder),
{ value, {} }) {
}
InputField::InputField(
QWidget *parent,
const style::InputField &st,
Mode mode,
rpl::producer<QString> placeholder,
const TextWithTags &value)
: RpWidget(parent)
, _st(st)
, _mode(mode)
, _minHeight(st.heightMin)
, _maxHeight(st.heightMax)
, _inner(std::make_unique<Inner>(this))
, _lastTextWithTags(value)
, _placeholderFull(std::move(placeholder)) {
#ifdef Q_OS_MAC
_systemTextReplaces = std::make_unique<SystemTextReplaces>();
#endif
_inner->setDocument(CreateChild<InputDocument>(_inner.get(), _st));
_inner->setAcceptRichText(false);
resize(_st.width, _minHeight);
if (_st.width > 0) {
setNaturalWidth(_st.width);
}
{ // In case of default fonts all those should be zero.
const auto metrics = QFontMetricsF(_st.style.font->f);
const auto realAscent = int(base::SafeRound(metrics.ascent()));
const auto ascentAdd = _st.style.font->ascent - realAscent;
//const auto realHeight = int(base::SafeRound(metrics.height()));
//const auto heightAdd = _font->height - realHeight - ascentAdd;
//_customFontMargins = QMargins(0, ascentAdd, 0, heightAdd);
_customFontMargins = QMargins(0, ascentAdd, 0, -ascentAdd);
// We move _inner down by ascentAdd for the first line to look
// at the same vertical position as in the default font.
//
// But we don't want to get vertical scroll in case the field
// fits pixel-perfect with the default font, so we allow the
// bottom margin to be the same shift, but negative.
if (_mode != Mode::SingleLine) {
const auto metrics = QFontMetricsF(_st.style.font->f);
const auto leading = qMax(metrics.leading(), qreal(0.0));
const auto adjustment = (metrics.ascent() + leading)
- ((_st.style.font->height * 4) / 5);
_placeholderCustomFontSkip = int(base::SafeRound(-adjustment));
}
}
if (_st.textBg->c.alphaF() >= 1. && !_st.borderRadius) {
setAttribute(Qt::WA_OpaquePaintEvent);
}
_inner->setFont(_st.style.font->f);
_inner->setAlignment(_st.textAlign);
if (_mode == Mode::SingleLine) {
_inner->setWordWrapMode(QTextOption::NoWrap);
}
_placeholderFull.value(
) | rpl::on_next([=](const QString &text) {
accessibilityNameChanged();
_inner->setAccessibleName(text);
refreshPlaceholder(text);
}, lifetime());
style::PaletteChanged(
) | rpl::on_next([=] {
updatePalette();
}, lifetime());
{
auto cursor = _inner->textCursor();
_defaultCharFormat = cursor.charFormat();
updatePalette();
cursor.setCharFormat(_defaultCharFormat);
cursor.setBlockFormat(PrepareBlockFormat(_st));
_inner->setTextCursor(cursor);
}
_inner->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
_inner->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
_inner->setFrameStyle(int(QFrame::NoFrame) | QFrame::Plain);
_inner->viewport()->setAutoFillBackground(false);
_inner->setContentsMargins(0, 0, 0, 0);
_inner->document()->setDocumentMargin(0);
base::qt_signal_producer(
_inner->document(),
&QTextDocument::contentsChange
) | rpl::on_next([=](int position, int removed, int added) {
documentContentsChanged(position, removed, added);
}, lifetime());
base::qt_signal_producer(
_inner.get(),
&QTextEdit::undoAvailable
) | rpl::on_next([=](bool undoAvailable) {
_undoAvailable = undoAvailable;
Integration::Instance().textActionsUpdated();
}, lifetime());
base::qt_signal_producer(
_inner.get(),
&QTextEdit::redoAvailable
) | rpl::on_next([=](bool redoAvailable) {
_redoAvailable = redoAvailable;
Integration::Instance().textActionsUpdated();
}, lifetime());
base::qt_signal_producer(
_inner.get(),
&QTextEdit::cursorPositionChanged
) | rpl::on_next([=] {
auto cursor = textCursor();
if (!cursor.hasSelection() && !cursor.position()) {
cursor.setCharFormat(_defaultCharFormat);
setTextCursor(cursor);
}
if (_customObject) {
_customObject->refreshSpoilerShown({
cursor.selectionStart(),
cursor.selectionEnd(),
});
}
}, lifetime());
base::qt_signal_producer(
_inner.get(),
&Inner::selectionChanged
) | rpl::on_next([] {
Integration::Instance().textActionsUpdated();
}, lifetime());
setupMarkdownShortcuts();
const auto bar = _inner->verticalScrollBar();
_scrollTop = bar->value();
connect(bar, &QScrollBar::valueChanged, [=] {
_scrollTop = bar->value();
});
setCursor(style::cur_text);
heightAutoupdated();
if (!value.text.isEmpty()) {
setTextWithTags(value, HistoryAction::Clear);
}
startBorderAnimation();
startPlaceholderAnimation();
finishAnimating();
}
std::vector<InputField::MarkdownAction> InputField::MarkdownActions() {
return {
{ QKeySequence::Bold, kTagBold },
{ QKeySequence::Italic, kTagItalic },
{ QKeySequence::Underline, kTagUnderline },
{ kStrikeOutSequence, kTagStrikeOut },
{ kMonospaceSequence, kTagCode },
{ kBlockquoteSequence, kTagBlockquote },
{ kSpoilerSequence, kTagSpoiler },
{ kClearFormatSequence, QString() },
{ kEditLinkSequence, QString(), MarkdownActionType::EditLink },
{ kEditDateSequence, QString(), MarkdownActionType::EditDate },
};
}
std::vector<InputField::MarkdownAction> InputField::MarkdownActionsNotes() {
return {
{ QKeySequence::Bold, kTagBold },
{ QKeySequence::Italic, kTagItalic },
{ QKeySequence::Underline, kTagUnderline },
{ kStrikeOutSequence, kTagStrikeOut },
{ kSpoilerSequence, kTagSpoiler },
{ kClearFormatSequence, QString() },
};
}
void InputField::setupMarkdownShortcuts() {
for (const auto &action : (_markdownSet == MarkdownSet::Notes ? MarkdownActionsNotes() : MarkdownActions())) {
auto shortcut = std::make_unique<QShortcut>(
action.sequence,
_inner.get(),
nullptr,
nullptr,
Qt::WidgetShortcut);
QObject::connect(shortcut.get(), &QShortcut::activated, [=] {
executeMarkdownAction(action);
});
_markdownShortcuts.push_back(std::move(shortcut));
}
}
void InputField::setMarkdownSet(MarkdownSet set) {
_markdownSet = set;
_markdownShortcuts.clear();
setupMarkdownShortcuts();
}
bool InputField::executeMarkdownAction(MarkdownAction action) {
if (_markdownEnabledState.disabled()) {
return false;
} else if (action.type == MarkdownActionType::EditLink) {
if (!_editLinkCallback) {
return false;
}
const auto cursor = textCursor();
editMarkdownLink({
cursor.selectionStart(),
cursor.selectionEnd()
});
} else if (action.type == MarkdownActionType::EditDate) {
if (!_editLinkCallback) {
return false;
}
const auto cursor = textCursor();
editMarkdownDate({
cursor.selectionStart(),
cursor.selectionEnd(),
});
} else if (action.tag.isEmpty()) {
clearSelectionMarkdown();
} else if (!_markdownEnabledState.enabledForTag(action.tag)
|| (action.tag == kTagCode
&& !_markdownEnabledState.enabledForTag(kTagPre))) {
return false;
} else {
toggleSelectionMarkdown(action.tag);
}
return true;
}
const rpl::variable<int> &InputField::scrollTop() const {
return _scrollTop;
}
int InputField::scrollTopMax() const {
return _inner->verticalScrollBar()->maximum();
}
void InputField::scrollTo(int top) {
_inner->verticalScrollBar()->setValue(top);
}
bool InputField::menuShown() const {
return _contextMenu != nullptr;
}
rpl::producer<bool> InputField::menuShownValue() const {
return _menuShownChanges.events_starting_with(menuShown());
}
void InputField::setPreCache(Fn<not_null<Text::QuotePaintCache*>()> make) {
_preCache = std::move(make);
update();
}
void InputField::setBlockquoteCache(
Fn<not_null<Text::QuotePaintCache*>()> make) {
_blockquoteCache = std::move(make);
update();
}
bool InputField::viewportEventInner(QEvent *e) {
if (e->type() == QEvent::Paint && _customObject) {
_customObject->setNow(crl::now());
}
return _inner->QTextEdit::viewportEvent(e);
}
void InputField::updatePalette() {
auto p = _inner->palette();
p.setColor(QPalette::Text, _st.textFg->c);
p.setColor(QPalette::Highlight, st::msgInBgSelected->c);
p.setColor(QPalette::HighlightedText, st::historyTextInFgSelected->c);
_inner->setPalette(p);
_defaultCharFormat.merge(PrepareTagFormat(
_st,
QString(),
_instantViewEditorTagsEnabled));
auto cursor = textCursor();
const auto document = _inner->document();
auto block = document->begin();
const auto end = document->end();
for (; block != end; block = block.next()) {
auto till = block.position();
for (auto i = block.begin(); !i.atEnd();) {
for (; !i.atEnd(); ++i) {
const auto fragment = i.fragment();
if (!fragment.isValid() || fragment.position() < till) {
continue;
}
till = fragment.position() + fragment.length();
auto format = fragment.charFormat();
const auto tag = format.property(kTagProperty).toString();
const auto updatedFormat = PrepareTagFormat(
_st,
tag,
_instantViewEditorTagsEnabled);
format.setFont(updatedFormat.font());
format.setVerticalAlignment(
updatedFormat.verticalAlignment());
format.setForeground(updatedFormat.foreground());
format.setBackground(updatedFormat.background());
format.setProperty(
kTagProperty,
updatedFormat.property(kTagProperty));
if (format.isImageFormat()
|| format.objectType() == kCustomEmojiFormat) {
format.setVerticalAlignment(QTextCharFormat::AlignTop);
}
cursor.setPosition(fragment.position());
cursor.setPosition(till, QTextCursor::KeepAnchor);
cursor.mergeCharFormat(format);
i = block.begin();
break;
}
}
}
cursor = textCursor();
if (!cursor.hasSelection()) {
auto format = cursor.charFormat();
format.merge(PrepareTagFormat(
_st,
TagWithoutCustomEmoji(
format.property(kTagProperty).toString()),
_instantViewEditorTagsEnabled));
cursor.setCharFormat(format);
setTextCursor(cursor);
}
}
void InputField::addContextMenuHook(ContextMenuHook hook) {
_contextMenuHooks.push_back(std::move(hook));
}
void InputField::setInstantReplaces(const InstantReplaces &replaces) {
_mutableInstantReplaces = replaces;
}
void InputField::setInstantReplacesEnabled(
rpl::producer<bool> enabled,
rpl::producer<bool> systemTextReplacesEnabled) {
std::move(
enabled
) | rpl::on_next([=](bool value) {
_instantReplacesEnabled = value;
}, lifetime());
if (systemTextReplacesEnabled) {
std::move(
systemTextReplacesEnabled
) | rpl::on_next([=](bool value) {
_systemTextReplacesEnabled = value;
}, lifetime());
}
}
void InputField::setMarkdownReplacesEnabled(bool enabled) {
setMarkdownReplacesEnabled(
rpl::single(MarkdownEnabledState{ MarkdownEnabled() }));
}
void InputField::setMarkdownReplacesEnabled(
rpl::producer<MarkdownEnabledState> enabled) {
std::move(
enabled
) | rpl::on_next([=](MarkdownEnabledState state) {
if (_markdownEnabledState != state) {
_markdownEnabledState = state;
if (_markdownEnabledState.disabled()) {
_lastMarkdownTags = {};
} else {
handleContentsChanged();
}
}
}, lifetime());
}
void InputField::setInstantViewEditorTagsEnabled(bool enabled) {
if (_instantViewEditorTagsEnabled == enabled) {
return;
}
_instantViewEditorTagsEnabled = enabled;
updatePalette();
handleContentsChanged();
}
void InputField::setTagMimeProcessor(Fn<QString(QStringView)> processor) {
_tagMimeProcessor = std::move(processor);
}
void InputField::setCustomTextContext(
Text::MarkedContext context,
Fn<bool()> pausedEmoji,
Fn<bool()> pausedSpoiler) {
_customObject = std::make_unique<CustomFieldObject>(
this,
std::move(context),
std::move(pausedEmoji),
std::move(pausedSpoiler));
_inner->document()->documentLayout()->registerHandler(
kCustomEmojiFormat,
_customObject.get());
_inner->document()->documentLayout()->registerHandler(
kCollapsedQuoteFormat,
_customObject.get());
}
void InputField::customEmojiRepaint() {
if (_customEmojiRepaintScheduled) {
return;
}
_customEmojiRepaintScheduled = true;
_inner->update();
}
void InputField::paintEventInner(QPaintEvent *e) {
_customEmojiRepaintScheduled = false;
paintQuotes(e);
_inner->QTextEdit::paintEvent(e);
}
void InputField::paintQuotes(QPaintEvent *e) {
if (!_blockquoteCache || !_preCache) {
return;
}
const auto clip = e->rect();
auto p = std::optional<QPainter>();
const auto ensurePainter = [&] {
if (!p) {
p.emplace(_inner->viewport());
}
};
auto shift = std::optional<QPoint>();
const auto ensureShift = [&] {
if (!shift) {
shift.emplace(
-_inner->horizontalScrollBar()->value(),
-_inner->verticalScrollBar()->value());
}
};
const auto document = _inner->document();
const auto documentLayout = document->documentLayout();
const auto collapsedCutoff = CollapsedQuoteCutoff(_st);
auto textSpoilerIt = begin(_spoilerRangesText);
const auto textSpoilerEnd = end(_spoilerRangesText);
auto textSpoiler = (TextRange*)nullptr;
const auto textSpoilerAdjust = [&](int position, int till) {
if (position >= till) {
textSpoiler = nullptr;
return;
}
while (textSpoilerIt != textSpoilerEnd
&& textSpoilerIt->till <= position) {
++textSpoilerIt;
}
textSpoiler = (textSpoilerIt != textSpoilerEnd
&& textSpoilerIt->from < till)
? &*textSpoilerIt
: nullptr;
};
auto emojiSpoilerIt = begin(_spoilerRangesEmoji);
const auto emojiSpoilerEnd = end(_spoilerRangesEmoji);
auto emojiSpoiler = (TextRange*)nullptr;
const auto emojiSpoilerAdjust = [&](int position, int till) {
if (position >= till) {
emojiSpoiler = nullptr;
return;
}
while (emojiSpoilerIt != emojiSpoilerEnd
&& emojiSpoilerIt->till <= position) {
++emojiSpoilerIt;
}
emojiSpoiler = (emojiSpoilerIt != emojiSpoilerEnd
&& emojiSpoilerIt->from < till)
? &*emojiSpoilerIt
: nullptr;
};
const auto spoilersAdjust = [&](int position, int till) {
textSpoilerAdjust(position, till);
emojiSpoilerAdjust(position, till);
};
_spoilerRects.resize(0);
auto lineStart = 0;
const auto addSpoiler = [&](QRectF rect, bool blockquote) {
auto normal = rect.toRect();
if (lineStart < _spoilerRects.size()) {
auto &last = _spoilerRects.back();
if (last.geometry.intersects(normal)) {
Assert(last.blockquote == blockquote);
last.geometry = last.geometry.united(normal);
return;
}
}
_spoilerRects.push_back({ normal, blockquote });
};
const auto finishSpoilersLine = [&] {
if (lineStart == _spoilerRects.size()) {
return;
}
ranges::sort(
_spoilerRects.begin() + lineStart,
_spoilerRects.end(),
ranges::less(),
[](const SpoilerRect &r) { return r.geometry.x(); });
auto i = _spoilerRects.begin() + lineStart;
auto j = i + 1;
while (j != end(_spoilerRects)) {
if (i->geometry.x() + i->geometry.width() >= j->geometry.x()) {
i->geometry = i->geometry.united(j->geometry);
j = _spoilerRects.erase(j);
} else {
i = j++;
}
}
lineStart = int(_spoilerRects.size());
};
auto block = document->firstBlock();
while (block.isValid()) {
auto belowClip = false;
auto blockRect = std::optional<QRectF>();
const auto ensureBlockRect = [&] {
if (!blockRect) {
blockRect.emplace(documentLayout->blockBoundingRect(block));
}
};
const auto blockPosition = block.position();
const auto format = block.blockFormat();
const auto id = format.property(kQuoteFormatId).toString();
const auto blockquote = (id == kTagBlockquote);
const auto collapsed = (id == kTagBlockquoteCollapsed);
const auto pre = !collapsed && IsTagPre(id);
spoilersAdjust(blockPosition, blockPosition + block.length());
if (textSpoiler || emojiSpoiler) {
ensureShift();
ensureBlockRect();
const auto fullShift = blockRect->topLeft() + *shift;
if (fullShift.y() >= clip.y() + clip.height()) {
belowClip = true;
} else if (fullShift.y() + blockRect->height() > clip.y()) {
const auto blockLayout = block.layout();
const auto lines = std::max(blockLayout->lineCount(), 0);
for (auto i = 0; i != lines; ++i) {
const auto line = blockLayout->lineAt(i);
const auto top = fullShift.y() + line.y();
const auto height = line.height();
if (top + height <= clip.y()) {
continue;
} else if (top >= clip.y() + clip.height()) {
belowClip = true;
break;
}
const auto lineFrom = blockPosition + line.textStart();
const auto lineTill = lineFrom + line.textLength();
textSpoilerAdjust(lineFrom, lineTill);
while (textSpoiler) {
const auto from = std::max(textSpoiler->from, lineFrom);
const auto runs = line.glyphRuns(
std::max(textSpoiler->from, lineFrom) - blockPosition,
std::min(textSpoiler->till, lineTill) - from);
for (const auto &run : runs) {
const auto runRect = run.boundingRect();
addSpoiler(
QRectF(
fullShift.x() + runRect.x(),
top,
runRect.width(),
height),
blockquote);
}
textSpoilerAdjust(textSpoiler->till, lineTill);
}
emojiSpoilerAdjust(lineFrom, lineTill);
while (emojiSpoiler) {
const auto from = std::max(emojiSpoiler->from, lineFrom);
const auto till = std::min(emojiSpoiler->till, lineTill);
const auto x = line.cursorToX(from - blockPosition);
const auto width = line.cursorToX(till - blockPosition) - x;
addSpoiler(
QRectF(
fullShift.x() + std::min(x, x + width),
top,
std::abs(width),
height),
blockquote);
emojiSpoilerAdjust(emojiSpoiler->till, lineTill);
}
finishSpoilersLine();
}
}
}
const auto st = pre
? &_st.style.pre
: (blockquote || collapsed)
? &_st.style.blockquote
: nullptr;
if (st) {
if (!shift) {
shift.emplace(
-_inner->horizontalScrollBar()->value(),
-_inner->verticalScrollBar()->value());
}
if (!blockRect) {
blockRect.emplace(documentLayout->blockBoundingRect(block));
}
const auto rect = blockRect->toRect();
const auto added = pre
? QMargins(0, 0, 0, st->verticalSkip)
: QMargins();
const auto target = ExtendForPaint(
rect.marginsAdded(added),
*st
).translated(*shift);
if (target.intersects(clip)) {
ensurePainter();
const auto cache = pre ? _preCache() : _blockquoteCache();
const auto collapsible = !pre
&& !collapsed
&& (rect.height() > collapsedCutoff);
Text::ValidateQuotePaintCache(*cache, *st);
Text::FillQuotePaint(*p, target, *cache, *st, {
.expandIcon = collapsed,
.collapseIcon = collapsible,
});
if (!pre) {
_blockquoteBg = cache->bg;
}
if (st->header > 0) {
const auto font = _st.style.font->monospace();
const auto topleft = target.topLeft();
const auto position = topleft + st->headerPosition;
const auto baseline = position + QPoint(0, font->ascent);
p->setFont(font);
p->setPen(st::defaultTextPalette.monoFg);
p->drawText(
baseline,
format.property(kPreLanguage).toString());
}
}
}
if (belowClip) {
break;
}
block = block.next();
}
}
void InputField::setPlaceholderColorOverride(const style::color &color) {
_placeholderFgOverride = color;
update();
}
void InputField::setDocumentMargin(float64 margin) {
_settingDocumentMargin = true;
document()->setDocumentMargin(margin);
_settingDocumentMargin = false;
}
void InputField::setAdditionalMargin(int margin) {
setAdditionalMargins({ margin, margin, margin, margin });
}
void InputField::setAdditionalMargins(QMargins margins) {
_additionalMargins = margins;
QResizeEvent e(size(), size());
QCoreApplication::sendEvent(this, &e);
}
void InputField::setMaxLength(int length) {
if (_maxLength != length) {
_maxLength = length;
if (_maxLength > 0) {
const auto document = _inner->document();
_correcting = true;
QTextCursor(document).joinPreviousEditBlock();
const auto guard = gsl::finally([&] {
_correcting = false;
QTextCursor(document).endEditBlock();
handleContentsChanged();
});
auto cursor = QTextCursor(document);
cursor.movePosition(QTextCursor::End);
chopByMaxLength(0, cursor.position());
}
}
}
void InputField::setMinHeight(int height) {
if (_minHeight == height) {
return;
}
_minHeight = height;
if (_maxHeight >= 0 && _maxHeight < height) {
_maxHeight = height;
}
heightAutoupdated();
}
void InputField::setMaxHeight(int height) {
if (_maxHeight == height) {
return;
}
_maxHeight = height;
if (_minHeight >= 0 && _minHeight > height) {
_minHeight = height;
}
heightAutoupdated();
}
void InputField::setMode(Mode mode) {
Expects(_mode == mode // Not supported.
|| (_mode != Mode::SingleLine && mode != Mode::SingleLine));
_mode = mode;
forceProcessContentsChanges();
}
void InputField::insertTag(const QString &text, QString tagId) {
auto cursor = textCursor();
const auto position = cursor.position();
const auto document = _inner->document();
auto block = document->findBlock(position);
for (auto iter = block.begin(); !iter.atEnd(); ++iter) {
auto fragment = iter.fragment();
Assert(fragment.isValid());
const auto fragmentPosition = fragment.position();
const auto fragmentEnd = (fragmentPosition + fragment.length());
if (fragmentPosition >= position || fragmentEnd < position) {
continue;
}
const auto format = fragment.charFormat();
if (format.isImageFormat()) {
continue;
}
auto mentionInCommand = false;
const auto fragmentText = fragment.text();
for (auto i = position - fragmentPosition; i > 0; --i) {
const auto previous = fragmentText[i - 1];
if (previous == '@' || previous == '#' || previous == '/') {
if ((i == position - fragmentPosition
|| (previous == '/' || previous == '@'
? fragmentText[i].isLetterOrNumber()
: fragmentText[i].isLetter())
|| previous == '#') &&
(i < 2 || !(fragmentText[i - 2].isLetterOrNumber()
|| fragmentText[i - 2] == '_'))) {
cursor.setPosition(fragmentPosition + i - 1);
auto till = fragmentPosition + i;
for (; (till < fragmentEnd && till < position); ++till) {
const auto ch = fragmentText[till - fragmentPosition];
if (!ch.isLetterOrNumber() && ch != '_' && ch != '@') {
break;
}
}
if (till < fragmentEnd
&& fragmentText[till - fragmentPosition] == ' ') {
++till;
}
cursor.setPosition(till, QTextCursor::KeepAnchor);
break;
} else if ((i == position - fragmentPosition
|| fragmentText[i].isLetterOrNumber())
&& fragmentText[i - 1] == '@'
&& (i > 2)
&& (fragmentText[i - 2].isLetterOrNumber()
|| fragmentText[i - 2] == '_')
&& !mentionInCommand) {
mentionInCommand = true;
--i;
continue;
}
break;
}
if (position - fragmentPosition - i > 127
|| (!mentionInCommand
&& (position - fragmentPosition - i > 63))
|| (!fragmentText[i - 1].isLetterOrNumber()
&& fragmentText[i - 1] != '_')) {
break;
}
}
break;
}
if (tagId.isEmpty()) {
cursor.insertText(text + ' ', _defaultCharFormat);
} else {
_insertedTags.clear();
_insertedTags.push_back({ 0, int(text.size()), tagId });
_insertedTagsAreFromMime = false;
cursor.insertText(text + ' ');
_insertedTags.clear();
}
}
bool InputField::heightAutoupdated() {
if (_minHeight < 0
|| _maxHeight < 0
|| _inHeightCheck
|| _mode == Mode::SingleLine) {
return false;
}
_inHeightCheck = true;
const auto guard = gsl::finally([&] { _inHeightCheck = false; });
SendPendingMoveResizeEvents(this);
const auto contentHeight = int(std::ceil(document()->size().height()))
+ _st.textMargins.top()
+ _st.textMargins.bottom()
+ _additionalMargins.top()
+ _additionalMargins.bottom();
const auto newHeight = std::clamp(contentHeight, _minHeight, _maxHeight);
if (height() != newHeight) {
resize(width(), newHeight);
return true;
}
return false;
}
void InputField::checkContentHeight() {
if (heightAutoupdated()) {
_heightChanges.fire({});
}
}
void InputField::paintSurrounding(
QPainter &p,
QRect clip,
float64 errorDegree,
float64 focusedDegree) {
if (_st.borderRadius > 0) {
paintRoundSurrounding(p, clip, errorDegree, focusedDegree);
} else {
paintFlatSurrounding(p, clip, errorDegree, focusedDegree);
}
}
void InputField::paintRoundSurrounding(
QPainter &p,
QRect clip,
float64 errorDegree,
float64 focusedDegree) {
const auto divide = _st.borderDenominator ? _st.borderDenominator : 1;
const auto border = _st.border / float64(divide);
const auto borderHalf = border / 2.;
auto pen = anim::pen(_st.borderFg, _st.borderFgActive, focusedDegree);
pen.setWidthF(border);
p.setPen(pen);
p.setBrush(anim::brush(_st.textBg, _st.textBgActive, focusedDegree));
PainterHighQualityEnabler hq(p);
const auto radius = _st.borderRadius - borderHalf;
p.drawRoundedRect(
QRectF(0, 0, width(), height()).marginsRemoved(
QMarginsF(borderHalf, borderHalf, borderHalf, borderHalf)),
radius,
radius);
}
void InputField::paintFlatSurrounding(
QPainter &p,
QRect clip,
float64 errorDegree,
float64 focusedDegree) {
if (_st.textBg->c.alphaF() > 0.) {
p.fillRect(clip, _st.textBg);
}
if (_st.border) {
p.fillRect(0, height() - _st.border, width(), _st.border, _st.borderFg);
}
const auto borderShownDegree = _a_borderShown.value(1.);
const auto borderOpacity = _a_borderOpacity.value(_borderVisible ? 1. : 0.);
if (_st.borderActive && (borderOpacity > 0.)) {
auto borderStart = std::clamp(_borderAnimationStart, 0, width());
auto borderFrom = qRound(borderStart * (1. - borderShownDegree));
auto borderTo = borderStart + qRound((width() - borderStart) * borderShownDegree);
if (borderTo > borderFrom) {
auto borderFg = anim::brush(_st.borderFgActive, _st.borderFgError, errorDegree);
p.setOpacity(borderOpacity);
p.fillRect(borderFrom, height() - _st.borderActive, borderTo - borderFrom, _st.borderActive, borderFg);
p.setOpacity(1);
}
}
}
void InputField::paintEvent(QPaintEvent *e) {
auto p = QPainter(this);
const auto r = rect().intersected(e->rect());
const auto errorDegree = _a_error.value(_error ? 1. : 0.);
const auto focusedDegree = _a_focused.value(_focused ? 1. : 0.);
paintSurrounding(p, r, errorDegree, focusedDegree);
const auto margins = placeholderPaintMargins();
if (_st.placeholderScale > 0. && !_placeholderPath.isEmpty()) {
auto placeholderShiftDegree = _a_placeholderShifted.value(_placeholderShifted ? 1. : 0.);
p.save();
p.setClipRect(r);
auto placeholderTop = anim::interpolate(0, _st.placeholderShift, placeholderShiftDegree);
QRect r(rect().marginsRemoved(margins));
r.moveTop(r.top() + placeholderTop);
if (style::RightToLeft()) r.moveLeft(width() - r.left() - r.width());
auto placeholderScale = 1. - (1. - _st.placeholderScale) * placeholderShiftDegree;
const auto &phFg = _placeholderFgOverride.value_or(_st.placeholderFg);
const auto &phFgActive = _placeholderFgOverride.value_or(_st.placeholderFgActive);
const auto &phFgError = _placeholderFgOverride.value_or(_st.placeholderFgError);
auto placeholderFg = anim::color(phFg, phFgActive, focusedDegree);
placeholderFg = anim::color(placeholderFg, phFgError, errorDegree);
PainterHighQualityEnabler hq(p);
p.setPen(Qt::NoPen);
p.setBrush(placeholderFg);
p.translate(r.topLeft());
p.scale(placeholderScale, placeholderScale);
p.drawPath(_placeholderPath);
p.restore();
} else if (!_placeholder.isEmpty()) {
const auto placeholderHiddenDegree = _a_placeholderShifted.value(_placeholderShifted ? 1. : 0.);
if (placeholderHiddenDegree < 1.) {
p.setOpacity(1. - placeholderHiddenDegree);
p.save();
p.setClipRect(r);
const auto placeholderLeft = anim::interpolate(0, -_st.placeholderShift, placeholderHiddenDegree);
p.setFont(_st.placeholderFont);
const auto &phFg2 = _placeholderFgOverride.value_or(_st.placeholderFg);
const auto &phFgActive2 = _placeholderFgOverride.value_or(_st.placeholderFgActive);
p.setPen(anim::pen(phFg2, phFgActive2, focusedDegree));
const auto baseline = nonScaledPlaceholderBaseline();
if (_st.placeholderAlign == style::al_topleft && _placeholderAfterSymbols > 0) {
const auto skipWidth = placeholderSkipWidth();
p.drawText(
QPointF(margins.left() + skipWidth, baseline),
_placeholder);
} else {
auto r = rect().marginsRemoved(margins);
r.moveLeft(r.left() + placeholderLeft);
if (style::RightToLeft()) r.moveLeft(width() - r.left() - r.width());
const auto align = HorizontalTextAlign(_st.placeholderAlign);
const auto textWidth = _st.placeholderFont->width(_placeholder);
const auto textLeft = r.left()
+ ((align & Qt::AlignHCenter)
? (r.width() - textWidth) / 2.
: (align & Qt::AlignRight)
? (r.width() - textWidth) * 1.
: 0.);
p.drawText(QPointF(textLeft, baseline), _placeholder);
}
p.restore();
}
}
RpWidget::paintEvent(e);
}
int InputField::placeholderSkipWidth() const {
if (!_placeholderAfterSymbols) {
return 0;
}
const auto &text = getTextWithTags().text;
auto result = _st.style.font->width(
text.mid(0, _placeholderAfterSymbols));
if (_placeholderAfterSymbols > text.size()) {
result += _st.style.font->spacew;
}
return result;
}
void InputField::startBorderAnimation() {
auto borderVisible = (_error || _focused);
if (_borderVisible != borderVisible) {
_borderVisible = borderVisible;
if (_borderVisible) {
if (_a_borderOpacity.animating()) {
_a_borderOpacity.start([this] { update(); }, 0., 1., _st.duration);
} else {
_a_borderShown.start([this] { update(); }, 0., 1., _st.duration);
}
} else {
_a_borderOpacity.start([this] { update(); }, 1., 0., _st.duration);
}
}
}
void InputField::focusInEvent(QFocusEvent *e) {
_borderAnimationStart = (e->reason() == Qt::MouseFocusReason)
? mapFromGlobal(QCursor::pos()).x()
: (width() / 2);
InvokeQueued(this, [=] {
if (QWidget::hasFocus()) {
focusInner();
}
});
}
void InputField::mousePressEvent(QMouseEvent *e) {
_borderAnimationStart = e->pos().x();
InvokeQueued(this, [=] { focusInner(); });
}
void InputField::mousePressEventInner(QMouseEvent *e) {
_selectedActionQuoteId = lookupActionQuoteId(e->pos());
_pressedActionQuoteId = _selectedActionQuoteId;
updateCursorShape();
if (_pressedActionQuoteId <= 0) {
_inner->QTextEdit::mousePressEvent(e);
}
}
void InputField::editPreLanguage(int quoteId, QStringView tag) {
Expects(IsTagPre(tag));
if (!_editLanguageCallback) {
return;
}
const auto apply = crl::guard(this, [=](QString language) {
auto block = FindBlock(document(), quoteId);
if (block.isValid()) {
const auto id = kTagPre + language;
_insertedTags = { { block.position(), block.length() - 1, id } };
auto cursor = QTextCursor(document());
cursor.setPosition(block.position());
cursor.setBlockFormat(PrepareBlockFormat(_st, id));
_insertedTags.clear();
}
});
_editLanguageCallback(tag.mid(kTagPre.size()).toString(), apply);
}
void InputField::trippleEnterExitBlock(QTextCursor &cursor) {
const auto block = cursor.block();
if (!HasBlockTag(block)) {
return;
}
const auto document = cursor.document();
const auto position = cursor.position();
const auto blockFrom = block.position();
const auto blockTill = blockFrom + block.length();
if (blockTill - blockFrom <= 3
|| (position != blockFrom + 3 && position != blockTill - 1)) {
return;
} else if (document->characterAt(position - 1) != kSoftLine
|| document->characterAt(position - 2) != kSoftLine
|| document->characterAt(position - 3) != kSoftLine) {
return;
}
const auto before = (position == blockFrom + 3);
cursor.setPosition(position - 3, QTextCursor::KeepAnchor);
cursor.insertText(
QString(QChar(kHardLine)),
before ? cursor.charFormat() : _defaultCharFormat);
if (before) {
cursor.setPosition(cursor.position() - 1);
}
cursor.setBlockFormat(PrepareBlockFormat(_st));
if (before) {
setTextCursor(cursor);
}
}
void InputField::toggleBlockquoteCollapsed(
int quoteId,
QStringView tag,
TextRange range) {
if (!_customObject) {
return;
}
const auto collapsed = (tag == kTagBlockquoteCollapsed);
auto text = getTextWithTagsPart(range.from, range.till);
for (auto i = text.tags.begin(); i != text.tags.end();) {
auto without = WithBlockTagRemoved(i->id);
if (without.isEmpty()) {
i = text.tags.erase(i);
} else {
i->id = std::move(without);
++i;
}
}
if (!collapsed) {
_customObject->setCollapsedText(quoteId, std::move(text));
}
_insertedTagsDelayClear = true;
const auto now = collapsed ? kTagBlockquote : kTagBlockquoteCollapsed;
auto cursor = QTextCursor(document());
cursor.beginEditBlock();
cursor.setPosition(range.from);
cursor.setPosition(range.till, QTextCursor::KeepAnchor);
cursor.removeSelectedText();
cursor.setBlockFormat(PrepareBlockFormat(_st, now, quoteId));
if (collapsed) {
insertWithTags(
{ range.from, range.from },
WrapInQuote(std::move(text), kTagBlockquote));
} else {
cursor.insertText(
kObjectReplacement,
PrepareCollapsedQuoteFormat(quoteId));
const auto now = cursor.position();
cursor.movePosition(QTextCursor::End);
if (cursor.position() == now) {
cursor.insertBlock(PrepareBlockFormat(_st), _defaultCharFormat);
} else {
cursor.setPosition(now + 1);
}
}
cursor.endEditBlock();
_insertedTagsDelayClear = false;
_insertedTags.clear();
_realInsertPosition = -1;
setTextCursor(cursor);
}
void InputField::blockActionClicked(int quoteId) {
auto block = FindBlock(document(), quoteId);
const auto format = block.blockFormat();
const auto tag = format.property(kQuoteFormatId).toString();
const auto blockTag = FindBlockTag(tag);
if (IsTagPre(blockTag)) {
editPreLanguage(quoteId, blockTag);
} else {
toggleBlockquoteCollapsed(
quoteId,
blockTag,
{ block.position(), block.position() + block.length() - 1 });
}
}
void InputField::mouseReleaseEventInner(QMouseEvent *e) {
_selectedActionQuoteId = lookupActionQuoteId(e->pos());
const auto taken = std::exchange(_pressedActionQuoteId, -1);
if (taken > 0 && taken == _selectedActionQuoteId) {
blockActionClicked(taken);
}
updateCursorShape();
_inner->QTextEdit::mouseReleaseEvent(e);
}
void InputField::mouseMoveEventInner(QMouseEvent *e) {
_selectedActionQuoteId = lookupActionQuoteId(e->pos());
updateCursorShape();
_inner->QTextEdit::mouseMoveEvent(e);
}
int InputField::lookupActionQuoteId(QPoint point) const {
auto shift = std::optional<QPoint>();
const auto document = _inner->document();
const auto layout = document->documentLayout();
const auto collapsedCutoff = CollapsedQuoteCutoff(_st);
auto block = document->firstBlock();
while (block.isValid()) {
const auto format = block.blockFormat();
const auto id = format.property(kQuoteFormatId).toString();
const auto collapsed = (id == kTagBlockquoteCollapsed);
const auto pre = !collapsed && IsTagPre(id);
const auto st = pre
? &_st.style.pre
: (id == kTagBlockquote || collapsed)
? &_st.style.blockquote
: nullptr;
if (st) {
if (!shift) {
shift.emplace(
-_inner->horizontalScrollBar()->value(),
-_inner->verticalScrollBar()->value());
}
const auto rect = layout->blockBoundingRect(block).toRect();
const auto added = IsTagPre(id)
? QMargins(0, 0, 0, st->verticalSkip)
: QMargins();
const auto target = ExtendForPaint(
rect.marginsAdded(added),
*st
).translated(*shift);
if (pre
&& QRect(
target.x(),
target.y(),
target.width(),
st->header).contains(point)) {
return format.property(kQuoteId).toInt();
} else if (!pre
&& (collapsed || rect.height() > collapsedCutoff)) {
const auto right = target.x() + target.width();
const auto bottom = target.y() + target.height();
const auto w = st->expand.width() + st->expandPosition.x();
const auto h = st->expand.height() + st->expandPosition.y();
if (QRect(right - w, bottom - h, w, h).contains(point)) {
return format.property(kQuoteId).toInt();
}
}
}
block = block.next();
}
return 0;
}
void InputField::updateCursorShape() {
const auto check = (_pressedActionQuoteId < 0)
? _selectedActionQuoteId
: _pressedActionQuoteId;
_inner->viewport()->setCursor(check > 0
? style::cur_pointer
: style::cur_text);
}
void InputField::leaveEventInner(QEvent *e) {
_selectedActionQuoteId = 0;
_inner->viewport()->setCursor(style::cur_text);
_inner->QTextEdit::leaveEvent(e);
}
void InputField::focusInner() {
auto borderStart = _borderAnimationStart;
_inner->setFocus();
_borderAnimationStart = borderStart;
}
int InputField::borderAnimationStart() const {
return _borderAnimationStart;
}
void InputField::contextMenuEvent(QContextMenuEvent *e) {
_inner->contextMenuEvent(e);
}
void InputField::focusInEventInner(QFocusEvent *e) {
_borderAnimationStart = (e->reason() == Qt::MouseFocusReason)
? mapFromGlobal(QCursor::pos()).x()
: (width() / 2);
setFocused(true);
_inner->QTextEdit::focusInEvent(e);
_focusedChanges.fire(true);
}
void InputField::focusOutEventInner(QFocusEvent *e) {
setFocused(false);
_inner->QTextEdit::focusOutEvent(e);
_focusedChanges.fire(false);
}
void InputField::setFocused(bool focused) {
if (_focused != focused) {
_focused = focused;
_a_focused.start([this] { update(); }, _focused ? 0. : 1., _focused ? 1. : 0., _st.duration);
startPlaceholderAnimation();
startBorderAnimation();
}
}
QSize InputField::sizeHint() const {
return geometry().size();
}
QSize InputField::minimumSizeHint() const {
return geometry().size();
}
bool InputField::hasText() const {
const auto document = _inner->document();
const auto from = document->begin();
const auto till = document->end();
if (from == till) {
return false;
}
for (auto item = from.begin(); !item.atEnd(); ++item) {
const auto fragment = item.fragment();
if (!fragment.isValid()) {
continue;
} else if (!fragment.text().isEmpty()) {
return true;
}
}
return (from.next() != till);
}
QString InputField::getTextPart(
int start,
int end,
TagList &outTagsList,
bool &outTagsChanged,
std::vector<MarkdownTag> *outMarkdownTags) const {
Expects((start == 0 && end < 0) || outMarkdownTags == nullptr);
if (end >= 0 && end <= start) {
outTagsChanged = !outTagsList.isEmpty();
outTagsList.clear();
return {};
}
if (start < 0) {
start = 0;
}
const auto full = (start == 0 && end < 0);
auto textSpoilers = std::optional<RangeAccumulator>();
auto emojiSpoilers = std::optional<RangeAccumulator>();
if (full) {
textSpoilers.emplace(_spoilerRangesText);
emojiSpoilers.emplace(_spoilerRangesEmoji);
}
auto lastTag = QString();
TagAccumulator tagAccumulator(outTagsList);
MarkdownTagAccumulator markdownTagAccumulator(outMarkdownTags);
const auto newline = outMarkdownTags ? QString(1, '\n') : QString();
const auto document = _inner->document();
const auto from = full ? document->begin() : document->findBlock(start);
auto till = (end < 0) ? document->end() : document->findBlock(end);
if (till.isValid()) {
till = till.next();
}
auto possibleLength = 0;
for (auto block = from; block != till; block = block.next()) {
possibleLength += block.length();
}
auto result = QString();
result.reserve(possibleLength);
if (!full && end < 0) {
end = possibleLength;
}
for (auto block = from; block != till;) {
// Only full blocks add block tags.
const auto blockFormat = full
? block.blockFormat()
: (start > block.position()
|| end + 1 < block.position() + block.length())
? QTextBlockFormat()
: block.blockFormat();
for (auto item = block.begin(); !item.atEnd(); ++item) {
const auto fragment = item.fragment();
if (!fragment.isValid()) {
continue;
}
const auto fragmentPosition = full ? 0 : fragment.position();
const auto fragmentEnd = full
? 0
: (fragmentPosition + fragment.length());
const auto format = fragment.charFormat();
if (!full) {
if (fragmentPosition == end) {
const auto tag = FullTag(format, blockFormat);
tagAccumulator.feed(tag, result.size());
break;
} else if (fragmentPosition > end) {
break;
} else if (fragmentEnd <= start) {
continue;
}
}
const auto emojiText = [&] {
if (format.isImageFormat()) {
const auto imageName = format.toImageFormat().name();
if (const auto emoji = Emoji::FromUrl(imageName)) {
return emoji->text();
}
}
return format.property(kCustomEmojiText).toString();
}();
auto text = [&] {
const auto result = fragment.text();
if (!full) {
if (fragmentPosition < start) {
return result.mid(start - fragmentPosition, end - start);
} else if (fragmentEnd > end) {
return result.mid(0, end - fragmentPosition);
}
}
return result;
}();
if (full || !text.isEmpty()) {
lastTag = FullTag(format, blockFormat);
tagAccumulator.feed(lastTag, result.size());
if (textSpoilers && HasSpoilerTag(lastTag)) {
const auto offset = fragment.position();
const auto length = fragment.length();
if (!emojiText.isEmpty()) {
emojiSpoilers->add(offset, length);
} else {
textSpoilers->add(offset, length);
}
}
}
auto begin = text.data();
auto ch = begin;
auto adjustedLength = text.size();
for (const auto end = begin + text.size(); ch != end; ++ch) {
if (IsNewline(*ch) && ch->unicode() != '\r') {
*ch = QLatin1Char('\n');
} else switch (ch->unicode()) {
case QChar::ObjectReplacementCharacter: {
if (ch > begin) {
result.append(begin, ch - begin);
}
const auto tag = blockFormat.property(kQuoteFormatId);
const auto quote = FindBlockTag(tag.toString());
if (quote == kTagBlockquoteCollapsed) {
auto collapsed = _customObject
? _customObject->collapsedText(
blockFormat.property(kQuoteId).toInt())
: TextWithTags();
adjustedLength += collapsed.text.size() - 1;
auto from = int(result.size());
tagAccumulator.feed(kTagBlockquoteCollapsed, from);
for (const auto &tag : collapsed.tags) {
tagAccumulator.feed(
TextUtilities::TagWithAdded(
tag.id,
kTagBlockquoteCollapsed),
from + tag.offset);
tagAccumulator.feed(
kTagBlockquoteCollapsed,
from + tag.offset + tag.length);
}
result.append(collapsed.text);
} else {
adjustedLength += emojiText.size() - 1;
if (!emojiText.isEmpty()) {
result.append(emojiText);
}
}
begin = ch + 1;
} break;
}
}
if (ch > begin) {
result.append(begin, ch - begin);
}
if (full || !text.isEmpty()) {
markdownTagAccumulator.feed(text, adjustedLength, lastTag);
}
}
block = block.next();
if (block != till) {
tagAccumulator.feed(
TagWithoutCustomEmoji(
FullTag(block.charFormat(), QTextBlockFormat())),
result.size());
result.append('\n');
markdownTagAccumulator.feed(newline, 1, lastTag);
}
}
tagAccumulator.feed(QString(), result.size());
tagAccumulator.finish();
markdownTagAccumulator.finish();
outTagsChanged = tagAccumulator.changed();
return result;
}
bool InputField::isUndoAvailable() const {
return _undoAvailable;
}
bool InputField::isRedoAvailable() const {
return _redoAvailable;
}
void InputField::undo() {
performUndoRedo(false);
}
void InputField::redo() {
performUndoRedo(true);
}
void InputField::performUndoRedo(bool redo) {
const auto wasPerformingUndoRedo = _performingUndoRedo;
_performingUndoRedo = true;
const auto guard = gsl::finally([&] {
_performingUndoRedo = wasPerformingUndoRedo;
});
if (redo) {
_inner->redo();
} else {
_inner->undo();
}
// Our contentsChange handler skips corrections while undo/redo
// is performed, so the document layout was not yet updated when
// the height was checked from there. Now the layout is updated.
checkContentHeight();
}
void InputField::processFormatting(int insertPosition, int insertEnd) {
// First tag handling (the one we inserted text to).
bool startTagFound = false;
bool breakTagOnNotLetter = false;
auto document = _inner->document();
// Apply inserted tags.
const auto insertedTagsProcessor = _insertedTagsAreFromMime
? (_tagMimeProcessor ? _tagMimeProcessor : DefaultTagMimeProcessor)
: nullptr;
auto breakTagOnNotLetterTill = ProcessInsertedTags(
_st,
document,
insertPosition,
insertEnd,
_insertedTags,
_insertedTagsReplace,
_instantViewEditorTagsEnabled,
insertedTagsProcessor);
using ActionType = FormattingAction::Type;
while (true) {
FormattingAction action;
auto blockTag = QString();
auto checkedTill = insertPosition;
auto fromBlock = document->findBlock(insertPosition);
auto tillBlock = document->findBlock(insertEnd);
if (tillBlock.isValid()) tillBlock = tillBlock.next();
for (auto block = fromBlock; block != tillBlock; block = block.next()) {
const auto blockFormat = block.blockFormat();
blockTag = FindBlockTag(
blockFormat.property(kQuoteFormatId).toString()).toString();
if (blockTag == kTagBlockquoteCollapsed) {
const auto id = blockFormat.property(kQuoteId).toInt();
if (!_customObject || !id || block.length() == 1) {
action.type = ActionType::RemoveBlockquote;
action.intervalStart = block.position();
break;
}
auto collapsedObjectPosition = FindCollapsedQuoteObject(block);
if (collapsedObjectPosition < 0) {
action.type = ActionType::CollapseBlockquote;
action.quoteId = id;
action.intervalStart = block.position();
action.intervalEnd = block.position() + block.length() - 1;
break;
} else if (collapsedObjectPosition > block.position()) {
action.type = ActionType::CutCollapsedBefore;
action.quoteId = id;
action.intervalStart = block.position();
action.intervalEnd = collapsedObjectPosition;
break;
} else if (collapsedObjectPosition + 2 < block.position() + block.length()) {
action.type = ActionType::CutCollapsedAfter;
action.intervalStart = collapsedObjectPosition + 1;
break;
}
continue;
} else if (blockFormat.lineHeightType() != QTextBlockFormat::FixedHeight
&& blockTag != kTagBlockquoteCollapsed) {
action.intervalStart = block.position();
action.type = ActionType::FixLineHeight;
break;
}
for (auto fragmentIt = block.begin(); !fragmentIt.atEnd(); ++fragmentIt) {
auto fragment = fragmentIt.fragment();
Assert(fragment.isValid());
const auto fragmentPosition = fragment.position();
const auto fragmentEnd = fragmentPosition + fragment.length();
if (insertPosition > fragmentEnd) {
// In case insertPosition == fragmentEnd we still
// need to fill startTagFound / breakTagOnNotLetter.
// This can happen if we inserted a newline after
// a text fragment with some formatting tag, like Bold.
continue;
}
int changedPositionInFragment = insertPosition - fragmentPosition; // Can be negative.
int changedEndInFragment = insertEnd - fragmentPosition;
if (changedEndInFragment < 0) {
break;
}
auto format = fragment.charFormat();
if (!format.hasProperty(kTagProperty)) {
action.type = ActionType::RemoveTag;
action.intervalStart = fragmentPosition;
action.intervalEnd = fragmentEnd;
break;
} else if (IsTagPre(blockTag)
&& blockTag != format.property(kTagProperty).toString()
&& format.objectType() != kCustomEmojiFormat) {
action.type = ActionType::FixPreTag;
action.intervalStart = fragmentPosition;
action.intervalEnd = fragmentEnd;
break;
}
auto fragmentText = fragment.text();
auto *textStart = fragmentText.constData();
auto *textEnd = textStart + fragmentText.size();
if (_customObject
&& format.objectType() == kCustomEmojiFormat) {
if (fragmentText == kObjectReplacement) {
checkedTill = fragmentEnd;
continue;
}
action.type = ActionType::InsertCustomEmoji;
action.intervalStart = fragmentPosition;
action.intervalEnd = fragmentPosition
+ fragmentText.size();
action.customEmojiText = fragmentText;
action.customEmojiLink = format.property(
kCustomEmojiLink).toString();
break;
} else if (_customObject
&& format.objectType() == kCollapsedQuoteFormat) {
action.type = ActionType::MakeCollapsedBlockquote;
action.quoteId = format.property(kQuoteId).toInt();
action.intervalStart = fragmentPosition;
action.intervalEnd = fragmentPosition + fragmentText.size();
break;
}
const auto with = format.property(kInstantReplaceWithId);
if (with.isValid()) {
const auto string = with.toString();
if (fragmentText != string) {
action.type = ActionType::ClearInstantReplace;
action.intervalStart = fragmentPosition
+ (fragmentText.startsWith(string)
? string.size()
: 0);
action.intervalEnd = fragmentPosition
+ fragmentText.size();
break;
}
}
if (format.hasProperty(kCustomEmojiLink)
&& !format.property(kCustomEmojiLink).toString().isEmpty()) {
action.type = ActionType::RemoveCustomEmoji;
action.existingTags = format.property(kTagProperty).toString();
action.intervalStart = fragmentPosition;
action.intervalEnd = fragmentPosition
+ fragmentText.size();
break;
}
if (!startTagFound) {
startTagFound = true;
auto tagName = format.property(kTagProperty).toString();
if (!tagName.isEmpty()) {
breakTagOnNotLetter = WasInsertTillTheEndOfTag(
block,
fragmentIt,
insertEnd);
}
}
auto *ch = textStart + qMax(changedPositionInFragment, 0);
for (; ch < textEnd; ++ch) {
const auto removeNewline = (_mode != Mode::MultiLine)
&& IsNewline(*ch);
if (removeNewline) {
if (action.type == ActionType::Invalid) {
action.type = ActionType::RemoveNewline;
action.intervalStart = fragmentPosition + (ch - textStart);
action.intervalEnd = action.intervalStart + 1;
}
break;
}
auto emojiLength = 0;
if (const auto emoji = Emoji::Find(ch, textEnd, &emojiLength)) {
// Replace emoji if no current action is prepared.
if (action.type == ActionType::Invalid) {
action.type = ActionType::InsertEmoji;
action.emoji = emoji;
action.intervalStart = fragmentPosition + (ch - textStart);
action.intervalEnd = action.intervalStart + emojiLength;
}
if (emojiLength > 1) {
_emojiSurrogateAmount += emojiLength - 1;
}
break;
}
if (breakTagOnNotLetter && !ch->isLetterOrNumber()) {
// Remove tag name till the end if no current action is prepared.
if (action.type != ActionType::Invalid) {
break;
}
breakTagOnNotLetter = false;
if (fragmentPosition + (ch - textStart) < breakTagOnNotLetterTill) {
action.type = ActionType::RemoveTag;
action.intervalStart = fragmentPosition + (ch - textStart);
action.intervalEnd = breakTagOnNotLetterTill;
break;
}
}
if (ch + 1 < textEnd && ch->isHighSurrogate() && (ch + 1)->isLowSurrogate()) {
++ch;
}
}
if (action.type != ActionType::Invalid) {
break;
}
checkedTill = fragmentEnd;
}
if (action.type != ActionType::Invalid) {
break;
} else if (_mode != Mode::MultiLine
&& block.next() != document->end()) {
action.type = ActionType::RemoveNewline;
action.intervalStart = block.next().position() - 1;
action.intervalEnd = action.intervalStart + 1;
break;
} else if (breakTagOnNotLetter) {
// In case we need to break on not letter and we didn't
// find any non letter symbol, we found it here - a newline.
breakTagOnNotLetter = false;
if (checkedTill < breakTagOnNotLetterTill) {
action.type = ActionType::RemoveTag;
action.intervalStart = checkedTill;
action.intervalEnd = breakTagOnNotLetterTill;
break;
}
}
}
if (action.type != ActionType::Invalid) {
PrepareFormattingOptimization(document);
if (action.type == ActionType::CollapseBlockquote) {
toggleBlockquoteCollapsed(
action.quoteId,
kTagBlockquote,
{ action.intervalStart, action.intervalEnd });
continue;
}
auto cursor = QTextCursor(document);
if (action.type == ActionType::CutCollapsedBefore) {
auto realCursor = textCursor();
const auto wasAtEdge = !realCursor.hasSelection()
&& realCursor.position() == action.intervalEnd;
cursor.setPosition(action.intervalEnd);
if (action.intervalEnd > 0
&& IsNewline(
document->characterAt(action.intervalEnd - 1))) {
cursor.setPosition(
action.intervalEnd - 1,
QTextCursor::KeepAnchor);
cursor.insertText(
QString(QChar(kHardLine)),
_defaultCharFormat);
} else {
cursor.insertBlock(
PrepareBlockFormat(
_st,
kTagBlockquoteCollapsed,
action.quoteId),
_defaultCharFormat);
}
cursor.setPosition(action.intervalStart);
cursor.setBlockFormat(PrepareBlockFormat(_st));
if (wasAtEdge) {
realCursor.setPosition(action.intervalEnd);
_formattingCursorUpdate = realCursor;
}
continue;
}
cursor.setPosition(action.intervalStart);
if (action.type == ActionType::FixLineHeight) {
cursor.setBlockFormat(PrepareBlockFormat(_st));
continue;
} else if (action.type == ActionType::CutCollapsedAfter) {
if (IsNewline(document->characterAt(action.intervalStart))) {
cursor.setPosition(
action.intervalStart + 1,
QTextCursor::KeepAnchor);
cursor.insertText(
QString(QChar(kHardLine)),
_defaultCharFormat);
} else {
cursor.insertBlock(
PrepareBlockFormat(_st),
_defaultCharFormat);
++insertEnd;
}
continue;
} else if (action.type == ActionType::RemoveBlockquote) {
cursor.setBlockFormat(PrepareBlockFormat(_st));
continue;
} else if (action.type == ActionType::MakeCollapsedBlockquote) {
auto text = _customObject->collapsedText(action.quoteId);
if (text.text.isEmpty()) {
cursor.setPosition(action.intervalEnd);
cursor.removeSelectedText();
} else {
const auto blockFormat = PrepareBlockFormat(
_st,
kTagBlockquoteCollapsed);
const auto id = blockFormat.property(kQuoteId).toInt();
_customObject->setCollapsedText(id, std::move(text));
const auto format = PrepareCollapsedQuoteFormat(id);
auto bBlock = document->findBlock(action.intervalStart);
if (bBlock.position() != action.intervalStart) {
cursor.insertText(QString(QChar(kHardLine)), format);
++action.intervalStart;
++action.intervalEnd;
}
cursor.setBlockFormat(blockFormat);
cursor.setBlockCharFormat(format);
const auto after = action.intervalEnd + 1;
auto eBlock = document->findBlock(after);
if (eBlock.position() != after) {
cursor.setPosition(action.intervalEnd);
cursor.insertBlock(
PrepareBlockFormat(_st),
_defaultCharFormat);
}
cursor.setPosition(action.intervalStart);
cursor.setPosition(
action.intervalEnd,
QTextCursor::KeepAnchor);
cursor.setCharFormat(format);
}
}
cursor.setPosition(action.intervalEnd, QTextCursor::KeepAnchor);
if (action.type == ActionType::InsertEmoji
|| action.type == ActionType::InsertCustomEmoji) {
if (action.type == ActionType::InsertEmoji) {
InsertEmojiAtCursor(cursor, action.emoji);
} else {
InsertCustomEmojiAtCursor(
this,
cursor,
action.customEmojiText,
action.customEmojiLink);
}
insertPosition = action.intervalStart + 1;
insertEnd = insertPosition
+ std::max(insertEnd - action.intervalEnd, 0);
if (breakTagOnNotLetterTill > action.intervalStart) {
breakTagOnNotLetterTill = insertPosition
+ std::max(
breakTagOnNotLetterTill - action.intervalEnd,
0);
}
} else if (action.type == ActionType::RemoveTag) {
RemoveDocumentTags(
_st,
document,
action.intervalStart,
action.intervalEnd);
} else if (action.type == ActionType::FixPreTag) {
cursor.setCharFormat(PrepareTagFormat(
_st,
blockTag,
_instantViewEditorTagsEnabled));
} else if (action.type == ActionType::RemoveCustomEmoji) {
RemoveCustomEmojiTag(
_st,
document,
action.existingTags,
_instantViewEditorTagsEnabled,
action.intervalStart,
action.intervalEnd);
} else if (action.type == ActionType::ClearInstantReplace) {
auto format = _defaultCharFormat;
ApplyTagFormat(format, cursor.charFormat());
cursor.setCharFormat(format);
} else if (action.type == ActionType::RemoveNewline) {
cursor.insertText(u" "_q);
insertPosition = action.intervalStart;
}
} else {
break;
}
}
}
void InputField::forceProcessContentsChanges() {
PostponeCall(this, [=] {
handleContentsChanged();
});
}
void InputField::documentContentsChanged(
int position,
int charsRemoved,
int charsAdded) {
if (_correcting) {
return;
}
// In case of input method events Qt emits
// document content change signals for a whole
// text block where the even took place.
// This breaks our wysiwyg markup, so we adjust
// the parameters to match the real change.
if (_inputMethodCommit.has_value()
&& charsAdded > _inputMethodCommit->size()
&& charsRemoved > 0) {
const auto inBlockBefore = charsAdded - _inputMethodCommit->size();
if (charsRemoved >= inBlockBefore) {
charsAdded -= inBlockBefore;
charsRemoved -= inBlockBefore;
position += inBlockBefore;
}
}
const auto document = _inner->document();
// Qt bug workaround https://bugreports.qt.io/browse/QTBUG-49062
if (!position) {
auto cursor = QTextCursor(document);
cursor.movePosition(QTextCursor::End);
if (position + charsAdded > cursor.position()) {
const auto delta = position + charsAdded - cursor.position();
if (charsRemoved >= delta) {
charsAdded -= delta;
charsRemoved -= delta;
}
}
}
const auto insertPosition = (_realInsertPosition >= 0)
? _realInsertPosition
: position;
const auto insertLength = (_realInsertPosition >= 0)
? _realCharsAdded
: charsAdded;
if (!_performingUndoRedo) {
_correcting = true;
QTextCursor(document).joinPreviousEditBlock();
chopByMaxLength(insertPosition, insertLength);
const auto inserted = (_maxLength > 0)
? (std::max(
std::min(insertPosition + insertLength, _maxLength),
insertPosition) - insertPosition)
: insertLength;
const auto pageSize = document->pageSize();
processFormatting(insertPosition, insertPosition + inserted);
if (document->pageSize() != pageSize) {
document->setPageSize(pageSize);
}
if (document->isEmpty()) {
textCursor().setBlockFormat(PrepareBlockFormat(_st));
}
updateRootFrameFormat();
_correcting = false;
QTextCursor(document).endEditBlock();
}
if (_formattingCursorUpdate) {
setTextCursor(*base::take(_formattingCursorUpdate));
ensureCursorVisible();
}
handleContentsChanged();
const auto added = charsAdded - _emojiSurrogateAmount;
_documentContentsChanges.fire({ position, charsRemoved, added });
_emojiSurrogateAmount = 0;
}
void InputField::updateRootFrameFormat() {
const auto document = _inner->document();
auto format = document->rootFrame()->frameFormat();
const auto propertyId = QTextFrameFormat::FrameTopMargin;
const auto topMargin = format.property(propertyId).toInt();
const auto wantedTopMargin = StartsWithPre(document)
? (_st.style.pre.padding.top()
+ _st.style.pre.header
+ _st.style.pre.verticalSkip)
: _requestedDocumentTopMargin;
if (_settingDocumentMargin) {
_requestedDocumentTopMargin = topMargin;
} else if (topMargin != wantedTopMargin) {
const auto value = QVariant::fromValue(1. * wantedTopMargin);
format.setProperty(propertyId, value);
document->rootFrame()->setFrameFormat(format);
}
}
void InputField::chopByMaxLength(int insertPosition, int insertLength) {
Expects(_correcting);
if (_maxLength < 0) {
return;
}
auto cursor = QTextCursor(document());
cursor.movePosition(QTextCursor::End);
const auto fullSize = cursor.position();
const auto toRemove = fullSize - _maxLength;
if (toRemove > 0) {
if (toRemove > insertLength) {
if (insertLength) {
cursor.setPosition(insertPosition);
cursor.setPosition(
(insertPosition + insertLength),
QTextCursor::KeepAnchor);
cursor.removeSelectedText();
}
cursor.setPosition(fullSize - (toRemove - insertLength));
cursor.setPosition(fullSize, QTextCursor::KeepAnchor);
cursor.removeSelectedText();
} else {
cursor.setPosition(
insertPosition + (insertLength - toRemove));
cursor.setPosition(
insertPosition + insertLength,
QTextCursor::KeepAnchor);
cursor.removeSelectedText();
}
}
}
void InputField::handleContentsChanged() {
setErrorShown(false);
auto tagsChanged = false;
const auto currentText = getTextPart(
0,
-1,
_lastTextWithTags.tags,
tagsChanged,
_markdownEnabledState.disabled() ? nullptr : &_lastMarkdownTags);
//highlightMarkdown();
if (_spoilerRangesText.empty() && _spoilerRangesEmoji.empty()) {
_spoilerOverlay = nullptr;
} else if (_customObject) {
if (!_spoilerOverlay) {
_spoilerOverlay = _customObject->createSpoilerOverlay();
_spoilerOverlay->setGeometry(_inner->rect());
}
const auto cursor = textCursor();
_customObject->refreshSpoilerShown({
cursor.selectionStart(),
cursor.selectionEnd(),
});
}
if (tagsChanged || (_lastTextWithTags.text != currentText)) {
_lastTextWithTags.text = currentText;
const auto weak = base::make_weak(this);
_changes.fire({});
if (!weak) {
return;
}
checkContentHeight();
}
startPlaceholderAnimation();
if (_lastTextWithTags.text.isEmpty()) {
if (const auto object = _customObject.get()) {
object->clearEmoji();
}
}
Integration::Instance().textActionsUpdated();
}
void InputField::highlightMarkdown() {
// Highlighting may interfere with markdown parsing -> inaccurate.
// For debug.
auto from = 0;
auto applyColor = [&](int a, int b, QColor color) {
auto cursor = textCursor();
cursor.setPosition(a);
cursor.setPosition(b, QTextCursor::KeepAnchor);
auto format = QTextCharFormat();
format.setForeground(color);
cursor.mergeCharFormat(format);
from = b;
};
for (const auto &tag : _lastMarkdownTags) {
if (tag.internalStart > from) {
applyColor(from, tag.internalStart, QColor(0, 0, 0));
} else if (tag.internalStart < from) {
continue;
}
applyColor(
tag.internalStart,
tag.internalStart + tag.internalLength,
(tag.closed
? QColor(0, 128, 0)
: QColor(128, 0, 0)));
}
auto cursor = textCursor();
cursor.movePosition(QTextCursor::End);
if (const auto till = cursor.position(); till > from) {
applyColor(from, till, QColor(0, 0, 0));
}
}
QMargins InputField::fullTextMargins() const {
const auto skip = int(base::SafeRound(
_inner->document()->documentMargin()));
return _st.textMargins
+ QMargins(skip, skip, skip, 0)
+ _additionalMargins
+ _customFontMargins;
}
QMargins InputField::placeholderPaintMargins() const {
return fullTextMargins()
+ QMargins(0, _placeholderCustomFontSkip, 0, 0)
+ _st.placeholderMargins;
}
float64 InputField::nonScaledPlaceholderBaseline() const {
return placeholderPaintMargins().top()
+ QTextEditLineAscent(_st.placeholderFont, BlockLineHeight(_st));
}
void InputField::setDisplayFocused(bool focused) {
setFocused(focused);
finishAnimating();
}
void InputField::selectAll() {
auto cursor = _inner->textCursor();
cursor.setPosition(0);
cursor.movePosition(QTextCursor::End, QTextCursor::KeepAnchor);
_inner->setTextCursor(cursor);
}
void InputField::finishAnimating() {
_a_focused.stop();
_a_error.stop();
_a_placeholderShifted.stop();
_a_borderShown.stop();
_a_borderOpacity.stop();
update();
}
void InputField::setPlaceholderHidden(bool forcePlaceholderHidden) {
_forcePlaceholderHidden = forcePlaceholderHidden;
startPlaceholderAnimation();
}
void InputField::startPlaceholderAnimation() {
const auto textLength = [&] {
return getTextWithTags().text.size() + _lastPreEditText.size();
};
const auto placeholderShifted = _forcePlaceholderHidden
|| (_focused && _st.placeholderScale > 0.)
|| (textLength() > _placeholderAfterSymbols);
if (_placeholderShifted != placeholderShifted) {
_placeholderShifted = placeholderShifted;
_a_placeholderShifted.start(
[=] { update(); },
_placeholderShifted ? 0. : 1.,
_placeholderShifted ? 1. : 0.,
_st.duration);
}
}
QMimeData *InputField::createMimeDataFromSelectionInner() const {
const auto cursor = _inner->textCursor();
const auto start = cursor.selectionStart();
const auto end = cursor.selectionEnd();
auto selected = (end > start)
? getTextWithTagsPart(start, end)
: TextWithTags();
const auto result = TextUtilities::MimeDataFromText(
std::move(selected)
).release();
return result ? result : new QMimeData;
}
void InputField::customUpDown(bool isCustom) {
_customUpDown = isCustom;
}
void InputField::setSubmitSettings(SubmitSettings settings) {
_submitSettings = settings;
}
not_null<QTextDocument*> InputField::document() {
return _inner->document();
}
not_null<const QTextDocument*> InputField::document() const {
return _inner->document();
}
void InputField::setTextCursor(const QTextCursor &cursor) {
return _inner->setTextCursor(cursor);
}
QTextCursor InputField::textCursor() const {
return _inner->textCursor();
}
void InputField::setCursorPosition(int pos) {
auto cursor = _inner->textCursor();
cursor.setPosition(pos);
_inner->setTextCursor(cursor);
}
void InputField::setText(const QString &text) {
setTextWithTags({ text, {} });
}
void InputField::setTextWithTags(
const TextWithTags &textWithTags,
HistoryAction historyAction) {
auto prepared = PrepareForInsert(textWithTags);
_insertedTags = prepared.tags;
_insertedTagsAreFromMime = false;
_realInsertPosition = 0;
_realCharsAdded = prepared.text.size();
const auto document = _inner->document();
auto cursor = QTextCursor(document);
if (historyAction == HistoryAction::Clear) {
document->setUndoRedoEnabled(false);
if (const auto object = _customObject.get()) {
object->clearEmoji();
object->clearQuotes();
}
cursor.beginEditBlock();
} else if (historyAction == HistoryAction::MergeEntry) {
cursor.joinPreviousEditBlock();
} else {
cursor.beginEditBlock();
}
cursor.movePosition(QTextCursor::End, QTextCursor::KeepAnchor);
cursor.insertText(prepared.text, _defaultCharFormat);
cursor.movePosition(QTextCursor::End);
cursor.endEditBlock();
if (historyAction == HistoryAction::Clear) {
document->setUndoRedoEnabled(true);
}
_insertedTags.clear();
_realInsertPosition = -1;
finishAnimating();
}
TextWithTags InputField::getTextWithTagsPart(int start, int end) const {
auto changed = false;
auto result = TextWithTags();
result.text = getTextPart(start, end, result.tags, changed);
return result;
}
TextWithTags InputField::getTextWithAppliedMarkdown() const {
if (_markdownEnabledState.disabled() || _lastMarkdownTags.empty()) {
return getTextWithTags();
}
const auto &originalText = _lastTextWithTags.text;
const auto &originalTags = _lastTextWithTags.tags;
// Ignore tags that partially intersect some http-links.
// This will allow sending http://test.com/__test__/test correctly.
const auto links = TextUtilities::ParseEntities(
originalText,
0).entities;
auto result = TextWithTags();
result.text.reserve(originalText.size());
result.tags.reserve(originalTags.size() + _lastMarkdownTags.size());
auto removed = 0;
auto originalTag = originalTags.begin();
const auto originalTagsEnd = originalTags.end();
const auto addOriginalTagsUpTill = [&](int offset) {
while (originalTag != originalTagsEnd
&& originalTag->offset + originalTag->length <= offset) {
result.tags.push_back(*originalTag++);
result.tags.back().offset -= removed;
}
};
auto from = 0;
const auto addOriginalTextUpTill = [&](int offset) {
if (offset > from) {
result.text.append(base::StringViewMid(originalText, from, offset - from));
}
};
auto link = links.begin();
const auto linksEnd = links.end();
for (const auto &tag : _lastMarkdownTags) {
const auto tagLength = int(tag.tag.size());
if (!tag.closed || tag.adjustedStart < from) {
continue;
}
auto entityLength = tag.adjustedLength - 2 * tagLength;
if (entityLength <= 0) {
continue;
}
addOriginalTagsUpTill(tag.adjustedStart);
const auto tagAdjustedEnd = tag.adjustedStart + tag.adjustedLength;
if (originalTag != originalTagsEnd
&& originalTag->offset < tagAdjustedEnd) {
continue;
}
while (link != linksEnd
&& link->offset() + link->length() <= tag.adjustedStart) {
++link;
}
if (link != linksEnd
&& link->offset() < tagAdjustedEnd
&& (link->offset() + link->length() > tagAdjustedEnd
|| link->offset() < tag.adjustedStart)) {
continue;
}
addOriginalTextUpTill(tag.adjustedStart);
auto tagId = tag.tag;
auto entityStart = tag.adjustedStart + tagLength;
if (tagId == kTagPre) {
// Remove redundant newlines for pre.
// If ``` is on a separate line add only one newline.
const auto languageName = ReadPreLanguageName(
originalText,
entityStart,
entityLength);
if (!languageName.isEmpty()) {
// ```language-name{\n}code
entityStart += languageName.size() + 1;
entityLength -= languageName.size() + 1;
tagId += languageName;
} else if (IsNewline(originalText[entityStart])
&& (result.text.isEmpty()
|| IsNewline(result.text[result.text.size() - 1]))) {
++entityStart;
--entityLength;
}
const auto entityEnd = entityStart + entityLength;
if (IsNewline(originalText[entityEnd - 1])
&& (originalText.size() <= entityEnd + tagLength
|| IsNewline(originalText[entityEnd + tagLength]))) {
--entityLength;
}
}
if (entityLength > 0) {
// Add tag text and entity.
result.tags.push_back(TextWithTags::Tag{
int(result.text.size()),
entityLength,
tagId });
result.text.append(base::StringViewMid(
originalText,
entityStart,
entityLength));
}
from = tag.adjustedStart + tag.adjustedLength;
removed += (tag.adjustedLength - entityLength);
}
addOriginalTagsUpTill(originalText.size());
addOriginalTextUpTill(originalText.size());
return result;
}
void InputField::clear() {
_inner->clear();
startPlaceholderAnimation();
if (const auto object = _customObject.get()) {
object->clearEmoji();
}
}
bool InputField::hasFocus() const {
return _inner->hasFocus();
}
void InputField::setFocus() {
_inner->setFocus();
}
void InputField::clearFocus() {
_inner->clearFocus();
}
void InputField::ensureCursorVisible() {
_inner->ensureCursorVisible();
}
not_null<QTextEdit*> InputField::rawTextEdit() {
return _inner.get();
}
not_null<const QTextEdit*> InputField::rawTextEdit() const {
return _inner.get();
}
bool InputField::ShouldSubmit(
SubmitSettings settings,
Qt::KeyboardModifiers modifiers) {
const auto shift = modifiers.testFlag(Qt::ShiftModifier);
const auto ctrl = modifiers.testFlag(Qt::ControlModifier)
|| modifiers.testFlag(Qt::MetaModifier);
return (ctrl && shift)
|| (ctrl
&& settings != SubmitSettings::None
&& settings != SubmitSettings::Enter)
|| (!ctrl
&& !shift
&& settings != SubmitSettings::None
&& settings != SubmitSettings::CtrlEnter);
}
void InputField::keyPressEventInner(QKeyEvent *e) {
const auto shift = e->modifiers().testFlag(Qt::ShiftModifier);
const auto alt = e->modifiers().testFlag(Qt::AltModifier);
const auto macmeta = ::Platform::IsMac()
&& e->modifiers().testFlag(Qt::ControlModifier)
&& !e->modifiers().testFlag(Qt::MetaModifier)
&& !e->modifiers().testFlag(Qt::AltModifier);
const auto ctrl = e->modifiers().testFlag(Qt::ControlModifier)
|| e->modifiers().testFlag(Qt::MetaModifier);
const auto enterSubmit = (_mode != Mode::MultiLine)
|| ShouldSubmit(_submitSettings, e->modifiers());
const auto key = e->key();
const auto enter = (key == Qt::Key_Enter || key == Qt::Key_Return);
const auto backspace = (key == Qt::Key_Backspace);
if (key == Qt::Key_Left
|| key == Qt::Key_Right
|| key == Qt::Key_Up
|| key == Qt::Key_Down
|| key == Qt::Key_Home
|| key == Qt::Key_End) {
_reverseMarkdownReplacement = false;
}
if (backspace && macmeta) {
QTextCursor tc(textCursor()), start(tc);
start.movePosition(QTextCursor::StartOfLine);
tc.setPosition(start.position(), QTextCursor::KeepAnchor);
tc.removeSelectedText();
} else if (backspace
&& e->modifiers() == 0
&& revertFormatReplace()) {
e->accept();
} else if (backspace && jumpOutOfBlockByBackspace()) {
e->accept();
} else if (enter && enterSubmit) {
_submits.fire(e->modifiers());
} else if (key == Qt::Key_Escape) {
e->ignore();
_cancelled.fire({});
} else if (key == Qt::Key_Tab || key == Qt::Key_Backtab) {
if (alt || ctrl) {
e->ignore();
} else {
auto handled = false;
_tabbed.fire(&handled);
if (!handled
&& !focusNextPrevChild(key == Qt::Key_Tab && !shift)) {
e->ignore();
}
}
} else if (key == Qt::Key_Search || e == QKeySequence::Find) {
e->ignore();
} else if (handleMarkdownKey(e)) {
e->accept();
} else if (IsPasteWithShift(e)) {
// Layout-independent Ctrl+Shift+V (Paste as Plain Text).
// insertFromMimeDataInner() looks at the live keyboard state
// to take the plain-text branch.
e->accept();
_inner->paste();
} else if (_customUpDown
&& (key == Qt::Key_Up
|| key == Qt::Key_Down
|| key == Qt::Key_PageUp
|| key == Qt::Key_PageDown)) {
e->ignore();
#ifdef Q_OS_MAC
} else if (key == Qt::Key_E && e->modifiers().testFlag(Qt::ControlModifier)) {
const auto cursor = textCursor();
const auto start = cursor.selectionStart();
const auto end = cursor.selectionEnd();
if (end > start) {
QGuiApplication::clipboard()->setText(
getTextWithTagsPart(start, end).text,
QClipboard::FindBuffer);
}
#endif // Q_OS_MAC
} else {
const auto text = e->text();
auto cursor = textCursor();
const auto oldPosition = cursor.position();
const auto oldSelection = cursor.hasSelection();
const auto oldModifiers = e->modifiers();
const auto allowedModifiers = (enter && ctrl)
? (~Qt::ControlModifier)
: (enter && shift)
? (~Qt::ShiftModifier)
: oldModifiers;
const auto changeModifiers = (oldModifiers & ~allowedModifiers) != 0;
const auto changedModifiers = (oldModifiers & allowedModifiers);
if (changeModifiers) {
e->setModifiers(changedModifiers);
}
const auto undoRedo = (e == QKeySequence::Undo)
|| (e == QKeySequence::Redo);
// If we enable this, the Undo/Redo will work through Key_Space
// insertions, because they will be in edit blocks with the following
// text char format changes. But this will make every entered letter
// have a separate Undo block without grouping input together.
//
//const auto createEditBlock = (e != QKeySequence::Undo)
// && (e != QKeySequence::Redo);
const auto createEditBlock = enter
|| backspace
|| (key == Qt::Key_Space)
|| (key == Qt::Key_Delete);
if (createEditBlock) {
cursor.beginEditBlock();
}
if (e == QKeySequence::InsertParagraphSeparator) {
// qtbase commit dbb9579566f3accd8aa5fe61db9692991117afd3 introduced
// special logic for repeated 'Enter' key presses, which drops the
// block format instead of inserting a newline in case the block format
// is non-trivial. For custom fonts we use non-trivial block formats
// always for the entire QTextEdit, so we revert that logic and simply
// insert a newline as it was before Qt 6.X.Y where this was added.
// Also we insert a kSoftLine instead of a block, because we want
// newlines to belong to the same block by default (blockquotes).
if (!cursor.hasSelection() && !HasBlockTag(cursor.block())) {
cursor.insertText(QString(QChar(kHardLine)));
} else {
cursor.insertText(QString(QChar(kSoftLine)));
trippleEnterExitBlock(cursor);
}
e->accept();
} else if (e == QKeySequence::Undo) {
performUndoRedo(false);
e->accept();
} else if (e == QKeySequence::Redo) {
performUndoRedo(true);
e->accept();
} else {
_inner->QTextEdit::keyPressEvent(e);
}
if (createEditBlock) {
cursor.endEditBlock();
}
if (key != Qt::Key_Control
&& key != Qt::Key_Shift
&& key != Qt::Key_Alt
&& key != Qt::Key_Meta) {
_inner->ensureCursorVisible();
}
if (changeModifiers) {
e->setModifiers(oldModifiers);
}
auto updatedCursor = textCursor();
if (!undoRedo && updatedCursor.position() == oldPosition) {
const auto shift = e->modifiers().testFlag(Qt::ShiftModifier);
bool check = false;
if (key == Qt::Key_PageUp || key == Qt::Key_Up) {
updatedCursor.movePosition(QTextCursor::Start, shift ? QTextCursor::KeepAnchor : QTextCursor::MoveAnchor);
check = true;
} else if (key == Qt::Key_PageDown || key == Qt::Key_Down) {
updatedCursor.movePosition(QTextCursor::End, shift ? QTextCursor::KeepAnchor : QTextCursor::MoveAnchor);
check = true;
} else if (!oldSelection
&& (key == Qt::Key_Left
|| key == Qt::Key_Right
|| key == Qt::Key_Backspace)) {
e->ignore();
}
if (check) {
if (oldPosition == updatedCursor.position()) {
if (shift || !exitQuoteWithNewBlock(key)) {
e->ignore();
}
} else {
setTextCursor(updatedCursor);
}
}
}
if (!undoRedo) {
if (!processMarkdownReplaces(text)) {
processInstantReplaces(text);
}
processSystemTextReplaces(text);
}
}
}
bool InputField::exitQuoteWithNewBlock(int key) {
const auto up = (key == Qt::Key_Up);
if (!up && key != Qt::Key_Down) {
return false;
}
auto cursor = textCursor();
if (cursor.hasSelection()
|| !cursor.blockFormat().hasProperty(kQuoteFormatId)) {
return false;
}
if (up) {
cursor.beginEditBlock();
cursor.insertText(QString(QChar(kHardLine)), _defaultCharFormat);
cursor.movePosition(QTextCursor::Start);
cursor.setBlockFormat(PrepareBlockFormat(_st));
cursor.setCharFormat(_defaultCharFormat);
cursor.endEditBlock();
setTextCursor(cursor);
checkContentHeight();
} else {
cursor.insertBlock(PrepareBlockFormat(_st), _defaultCharFormat);
}
_inner->ensureCursorVisible();
return true;
}
TextWithTags InputField::getTextWithTagsSelected() const {
const auto cursor = textCursor();
const auto start = cursor.selectionStart();
const auto end = cursor.selectionEnd();
return (end > start) ? getTextWithTagsPart(start, end) : TextWithTags();
}
bool InputField::handleMarkdownKey(QKeyEvent *e) {
if (_markdownEnabledState.disabled()) {
return false;
}
const auto matches = [&](const QKeySequence &sequence) {
const auto searchKey = (e->modifiers() | e->key())
& ~(Qt::KeypadModifier | Qt::GroupSwitchModifier);
const auto events = QKeySequence(searchKey);
return sequence.matches(events) == QKeySequence::ExactMatch;
};
for (const auto &action : (_markdownSet == MarkdownSet::Notes ? MarkdownActionsNotes() : MarkdownActions())) {
if (matches(action.sequence)) {
return executeMarkdownAction(action);
}
}
return false;
}
auto InputField::selectionEditLinkData(EditLinkSelection selection) const
-> EditLinkData {
Expects(_editLinkCallback != nullptr);
const auto position = (selection.from == selection.till
&& selection.from > 0)
? (selection.from - 1)
: selection.from;
const auto link = [&] {
return (position != selection.till)
? CheckFullTextTag(
getTextWithTagsPart(position, selection.till),
kTagCheckLinkMeta,
_instantViewEditorTagsEnabled)
: QString();
}();
const auto simple = EditLinkData{
selection.from,
selection.till,
QString()
};
if (!_editLinkCallback(selection, {}, link, EditLinkAction::Check)) {
return simple;
}
Assert(!link.isEmpty());
struct State {
QTextBlock block;
QTextBlock::iterator i;
};
const auto document = _inner->document();
const auto skipInvalid = [&](State &state) {
if (state.block == document->end()) {
return false;
}
while (state.i.atEnd()) {
state.block = state.block.next();
if (state.block == document->end()) {
return false;
}
state.i = state.block.begin();
}
return true;
};
const auto moveToNext = [&](State &state) {
Expects(state.block != document->end());
Expects(!state.i.atEnd());
++state.i;
};
const auto moveToPrevious = [&](State &state) {
Expects(state.block != document->end());
Expects(!state.i.atEnd());
while (state.i == state.block.begin()) {
if (state.block == document->begin()) {
state.block = document->end();
return false;
}
state.block = state.block.previous();
state.i = state.block.end();
}
--state.i;
return true;
};
const auto stateTag = [&](const State &state) {
const auto format = state.i.fragment().charFormat();
return format.property(kTagProperty).toString();
};
const auto stateTagHasLink = [&](const State &state) {
const auto tag = stateTag(state);
return (tag == link)
|| TextUtilities::SplitTags(tag).contains(QStringView(link));
};
const auto stateStart = [&](const State &state) {
return state.i.fragment().position();
};
const auto stateEnd = [&](const State &state) {
const auto fragment = state.i.fragment();
return fragment.position() + fragment.length();
};
auto state = State{ document->findBlock(position) };
if (state.block != document->end()) {
state.i = state.block.begin();
}
for (; skipInvalid(state); moveToNext(state)) {
const auto fragmentStart = stateStart(state);
const auto fragmentEnd = stateEnd(state);
if (fragmentEnd <= position) {
continue;
} else if (fragmentStart >= selection.till) {
break;
}
if (stateTagHasLink(state)) {
auto start = fragmentStart;
auto finish = fragmentEnd;
auto copy = state;
while (moveToPrevious(copy) && stateTagHasLink(copy)) {
start = stateStart(copy);
}
while (skipInvalid(state) && stateTagHasLink(state)) {
finish = stateEnd(state);
moveToNext(state);
}
return { start, finish, link };
}
}
return simple;
}
auto InputField::editLinkSelection(QContextMenuEvent *e) const
-> EditLinkSelection {
const auto cursor = textCursor();
if (!cursor.hasSelection() && e->reason() == QContextMenuEvent::Mouse) {
const auto clickCursor = _inner->cursorForPosition(
_inner->viewport()->mapFromGlobal(e->globalPos()));
if (!clickCursor.isNull() && !clickCursor.hasSelection()) {
return {
clickCursor.position(),
clickCursor.position()
};
}
}
return {
cursor.selectionStart(),
cursor.selectionEnd()
};
}
InputFieldTextRange InputField::selectionEditMarkdownTagRange(
InputFieldTextRange selection,
const QString &tag) const {
if (tag.isEmpty() || !selection.empty()) {
return selection;
}
const auto position = (selection.from > 0)
? (selection.from - 1)
: selection.from;
struct State {
QTextBlock block;
QTextBlock::iterator i;
};
const auto document = _inner->document();
const auto skipInvalid = [&](State &state) {
if (state.block == document->end()) {
return false;
}
while (state.i.atEnd()) {
state.block = state.block.next();
if (state.block == document->end()) {
return false;
}
state.i = state.block.begin();
}
return true;
};
const auto moveToNext = [&](State &state) {
Expects(state.block != document->end());
Expects(!state.i.atEnd());
++state.i;
};
const auto moveToPrevious = [&](State &state) {
Expects(state.block != document->end());
Expects(!state.i.atEnd());
while (state.i == state.block.begin()) {
if (state.block == document->begin()) {
state.block = document->end();
return false;
}
state.block = state.block.previous();
state.i = state.block.end();
}
--state.i;
return true;
};
const auto stateTagHasMatch = [&](const State &state) {
const auto format = state.i.fragment().charFormat();
return TextUtilities::SplitTags(
format.property(kTagProperty).toString()).contains(tag);
};
const auto stateStart = [&](const State &state) {
return state.i.fragment().position();
};
const auto stateEnd = [&](const State &state) {
const auto fragment = state.i.fragment();
return fragment.position() + fragment.length();
};
auto state = State{ document->findBlock(position) };
if (state.block != document->end()) {
state.i = state.block.begin();
}
for (; skipInvalid(state); moveToNext(state)) {
const auto fragmentStart = stateStart(state);
const auto fragmentEnd = stateEnd(state);
if (fragmentEnd <= position) {
continue;
} else if (fragmentStart > position) {
break;
}
if (stateTagHasMatch(state)) {
auto from = fragmentStart;
auto till = fragmentEnd;
auto copy = state;
while (moveToPrevious(copy) && stateTagHasMatch(copy)) {
from = stateStart(copy);
}
while (skipInvalid(state) && stateTagHasMatch(state)) {
till = stateEnd(state);
moveToNext(state);
}
return { from, till };
}
break;
}
return selection;
}
TextWithTags InputField::prepareTextStrippingLinks(
EditLinkSelection selection,
EditLinkData *outData) {
const auto data = selectionEditLinkData(selection);
if (outData) {
*outData = data;
}
auto text = getTextWithTagsPart(data.from, data.till);
for (auto i = text.tags.begin(); i != text.tags.end();) {
auto all = TextUtilities::SplitTags(i->id);
for (auto j = all.begin(); j != all.end();) {
if (IsEditableLinkTag(*j, _instantViewEditorTagsEnabled)) {
j = all.erase(j);
} else {
++j;
}
}
if (all.empty()) {
i = text.tags.erase(i);
} else {
i->id = TextUtilities::JoinTag(all);
++i;
}
}
return text;
}
void InputField::editMarkdownLink(EditLinkSelection selection) {
if (!_editLinkCallback) {
return;
}
auto data = EditLinkData();
auto text = prepareTextStrippingLinks(selection, &data);
_editLinkCallback(selection, text, data.link, EditLinkAction::Edit);
}
void InputField::editMarkdownDate(EditLinkSelection selection) {
if (!_editLinkCallback) {
return;
}
auto data = EditLinkData();
auto text = prepareTextStrippingLinks(selection, &data);
if (text.text.isEmpty() && !IsCustomDateLink(data.link)) {
return;
}
const auto existingDate = IsCustomDateLink(data.link)
? data.link
: (kCustomDateTagStart + u"0"_q);
_editLinkCallback(selection, text, existingDate, EditLinkAction::Edit);
}
void InputField::inputMethodEventInner(QInputMethodEvent *e) {
const auto preedit = e->preeditString();
if (_lastPreEditText != preedit) {
_lastPreEditText = preedit;
startPlaceholderAnimation();
}
if (!e->commitString().isEmpty()) {
if (Emoji::Find(e->commitString(), nullptr)) {
// Finalize the active IME composition in the underlying QTextEdit.
// See https://github.com/telegramdesktop/tdesktop/issues/29806
auto clear = QInputMethodEvent();
_inner->QTextEdit::inputMethodEvent(&clear);
if (!_lastPreEditText.isEmpty()) {
_lastPreEditText = QString();
startPlaceholderAnimation();
}
auto mimeData = QMimeData();
mimeData.setText(e->commitString());
InputField::insertFromMimeDataInner(&mimeData);
return;
}
}
_inputMethodCommit = e->commitString();
const auto weak = base::make_weak(this);
_inner->QTextEdit::inputMethodEvent(e);
if (weak && _inputMethodCommit.has_value()) {
const auto text = *base::take(_inputMethodCommit);
if (!processMarkdownReplaces(text)) {
processInstantReplaces(text);
}
processSystemTextReplaces(text);
}
}
const InstantReplaces &InputField::instantReplaces() const {
return _mutableInstantReplaces;
}
// Disable markdown instant replacement.
bool InputField::processMarkdownReplaces(const QString &appended) {
//if (appended.size() != 1 || !_markdownEnabled) {
// return false;
//}
//const auto ch = appended[0];
//if (ch == '`') {
// return processMarkdownReplace(kTagCode)
// || processMarkdownReplace(kTagPre);
//} else if (ch == '*') {
// return processMarkdownReplace(kTagBold);
//} else if (ch == '_') {
// return processMarkdownReplace(kTagItalic);
//}
return false;
}
//bool InputField::processMarkdownReplace(const QString &tag) {
// const auto position = textCursor().position();
// const auto tagLength = tag.size();
// const auto start = [&] {
// for (const auto &possible : _lastMarkdownTags) {
// const auto end = possible.start + possible.length;
// if (possible.start + 2 * tagLength >= position) {
// return MarkdownTag();
// } else if (end >= position || end + tagLength == position) {
// if (possible.tag == tag) {
// return possible;
// }
// }
// }
// return MarkdownTag();
// }();
// if (start.tag.isEmpty()) {
// return false;
// }
// return commitMarkdownReplacement(start.start, position, tag, tag);
//}
void InputField::processInstantReplaces(const QString &appended) {
const auto &replaces = instantReplaces();
if (appended.size() != 1
|| !_instantReplacesEnabled
|| !replaces.maxLength) {
return;
}
const auto it = replaces.reverseMap.tail.find(appended[0]);
if (it == end(replaces.reverseMap.tail)) {
return;
}
const auto position = textCursor().position();
for (const auto &tag : _lastMarkdownTags) {
if (tag.internalStart < position
&& tag.internalStart + tag.internalLength >= position
&& (tag.tag == kTagCode || IsTagPre(tag.tag))) {
return;
}
}
const auto typed = getTextWithTagsPart(
std::max(position - replaces.maxLength, 0),
position - 1).text;
auto node = &it->second;
auto i = typed.size();
do {
if (!node->text.isEmpty()) {
applyInstantReplace(typed.mid(i) + appended, node->text);
return;
} else if (!i) {
return;
}
const auto it = node->tail.find(typed[--i]);
if (it == end(node->tail)) {
return;
}
node = &it->second;
} while (true);
}
void InputField::processSystemTextReplaces(const QString &appended) {
if (!_systemTextReplacesEnabled
|| !_systemTextReplaces
|| appended.size() != 1
|| appended[0].isLetterOrNumber()) {
return;
}
const auto position = textCursor().position();
if (position < 2) {
return;
}
for (const auto &tag : _lastMarkdownTags) {
if (tag.internalStart < position
&& tag.internalStart + tag.internalLength >= position
&& (tag.tag == kTagCode || IsTagPre(tag.tag))) {
return;
}
}
const auto lookBack = std::min(position - 1, 100);
const auto from = position - 1 - lookBack;
auto selectCursor = textCursor();
selectCursor.setPosition(from);
selectCursor.setPosition(position - 1, QTextCursor::KeepAnchor);
const auto textBefore = selectCursor.selectedText();
if (textBefore.isEmpty()) {
return;
}
auto endAnchor = textCursor();
endAnchor.setPosition(position - 1);
const auto id = ++_systemTextReplaces->nextId;
_systemTextReplaces->pending.push_back({
.id = id,
.endAnchor = endAnchor,
.textSent = textBefore,
});
const auto weak = base::make_weak(this);
crl::async([text = textBefore, weak, id] {
const auto result = Platform::FindSystemTextReplace(text);
crl::on_main(weak, [=] {
weak->applySystemTextReplace(
id,
result.length,
result.replacement);
});
});
}
void InputField::applySystemTextReplace(
uint64 id,
int matchLength,
const QString &replacement) {
if (!_systemTextReplaces) {
return;
}
const auto it = ranges::find(
_systemTextReplaces->pending,
id,
&SystemTextReplaces::PendingCheck::id);
if (it == end(_systemTextReplaces->pending)) {
return;
}
const auto pending = *it;
_systemTextReplaces->pending.erase(it);
if (matchLength <= 0) {
return;
}
const auto anchorPos = pending.endAnchor.position();
const auto from = anchorPos - matchLength;
if (from < 0) {
return;
}
const auto till = anchorPos;
const auto original = pending.textSent.mid(
pending.textSent.size() - matchLength);
auto sanitized = replacement;
if (_mode != Mode::MultiLine) {
sanitized.replace('\n', ' ');
sanitized.replace('\r', ' ');
}
commitInstantReplacement(
from,
till,
sanitized,
QString(),
original,
true);
}
void InputField::applyInstantReplace(
const QString &what,
const QString &with) {
const auto length = int(what.size());
const auto cursor = textCursor();
const auto position = cursor.position();
if (cursor.hasSelection()) {
return;
} else if (position < length) {
return;
}
commitInstantReplacement(
position - length,
position,
with,
QString(),
what,
true);
}
void InputField::commitInstantReplacement(
int from,
int till,
const QString &with,
const QString &customEmojiData) {
commitInstantReplacement(
from,
till,
with,
customEmojiData,
std::nullopt,
false);
}
void InputField::commitInstantReplacement(
int from,
int till,
const QString &with,
const QString &customEmojiData,
std::optional<QString> checkOriginal,
bool checkIfInMonospace) {
const auto original = getTextWithTagsPart(from, till).text;
if (checkOriginal
&& checkOriginal->compare(original, Qt::CaseInsensitive) != 0) {
return;
}
auto cursor = textCursor();
if (checkIfInMonospace) {
const auto currentTag = cursor.charFormat().property(
kTagProperty
).toString();
for (const auto &tag : TextUtilities::SplitTags(currentTag)) {
if (tag == kTagCode || IsTagPre(tag)) {
return;
}
}
}
cursor.setPosition(from);
cursor.setPosition(till, QTextCursor::KeepAnchor);
const auto link = customEmojiData.isEmpty()
? QString()
: CustomEmojiLink(customEmojiData);
const auto unique = link.isEmpty()
? QString()
: MakeUniqueCustomEmojiLink(link);
auto format = [&]() -> QTextCharFormat {
auto emojiLength = 0;
const auto emoji = Emoji::Find(with, &emojiLength);
if (!emoji || with.size() != emojiLength) {
return _defaultCharFormat;
} else if (!customEmojiData.isEmpty()) {
auto result = QTextCharFormat();
result.setObjectType(kCustomEmojiFormat);
result.setProperty(kCustomEmojiText, with);
result.setProperty(kCustomEmojiLink, unique);
result.setProperty(kCustomEmojiId, CustomEmojiIdFromLink(link));
result.setVerticalAlignment(QTextCharFormat::AlignTop);
return result;
}
const auto use = Integration::Instance().defaultEmojiVariant(
emoji);
return PrepareEmojiFormat(use, _st.style.font);
}();
const auto replacement = (format.isImageFormat()
|| format.objectType() == kCustomEmojiFormat)
? kObjectReplacement
: with;
format.setProperty(kInstantReplaceWhatId, original);
format.setProperty(kInstantReplaceWithId, replacement);
format.setProperty(
kInstantReplaceRandomId,
base::RandomValue<uint32>());
ApplyTagFormat(format, cursor.charFormat());
if (format.isImageFormat() || format.objectType() == kCustomEmojiFormat) {
format.setVerticalAlignment(QTextCharFormat::AlignTop);
format.setProperty(kTagProperty, TagWithoutInstantViewMath(
format.property(kTagProperty).toString()));
}
if (!unique.isEmpty()) {
format.setProperty(kTagProperty, TagWithAddedDroppingMath(
format.property(kTagProperty).toString(),
unique,
_instantViewEditorTagsEnabled));
}
cursor.insertText(replacement, format);
}
#if 0
bool InputField::commitMarkdownReplacement(
int from,
int till,
const QString &tag,
const QString &edge) {
const auto end = [&] {
auto cursor = QTextCursor(document());
cursor.movePosition(QTextCursor::End);
return cursor.position();
}();
// In case of 'pre' tag extend checked text by one symbol.
// So that we'll know if we need to insert additional newlines.
// "Test ```test``` Test" should become three-line text.
const auto blocktag = (tag == kTagPre);
const auto extendLeft = (blocktag && from > 0) ? 1 : 0;
const auto extendRight = (blocktag && till < end) ? 1 : 0;
const auto extended = getTextWithTagsPart(
from - extendLeft,
till + extendRight).text;
const auto outer = base::StringViewMid(
extended,
extendLeft,
extended.size() - extendLeft - extendRight);
if ((outer.size() <= 2 * edge.size())
|| (!edge.isEmpty()
&& !(outer.startsWith(edge) && outer.endsWith(edge)))) {
return false;
}
// In case of 'pre' tag check if we need to remove one of two newlines.
// "Test\n```\ntest\n```" should become two-line text + newline.
const auto innerRight = edge.size();
const auto checkIfTwoNewlines = blocktag
&& (extendLeft > 0)
&& IsNewline(extended[0]);
const auto innerLeft = [&] {
const auto simple = edge.size();
if (!checkIfTwoNewlines) {
return simple;
}
const auto last = outer.size() - innerRight;
for (auto check = simple; check != last; ++check) {
const auto ch = outer.at(check);
if (IsNewline(ch)) {
return check + 1;
} else if (!Text::IsSpace(ch)) {
break;
}
}
return simple;
}();
const auto innerLength = outer.size() - innerLeft - innerRight;
// Prepare the final "insert" replacement for the "outer" text part.
const auto newlineleft = blocktag
&& (extendLeft > 0)
&& !IsNewline(extended[0])
&& !IsNewline(outer.at(innerLeft));
const auto newlineright = blocktag
&& (!extendRight || !IsNewline(extended[extended.size() - 1]))
&& !IsNewline(outer.at(outer.size() - innerRight - 1));
const auto insert = (newlineleft ? "\n" : "")
+ outer.mid(innerLeft, innerLength).toString()
+ (newlineright ? "\n" : "");
// Trim inserted tag, so that all newlines are left outside.
_insertedTags.clear();
auto tagFrom = newlineleft ? 1 : 0;
auto tagTill = insert.size() - (newlineright ? 1 : 0);
for (; tagFrom != tagTill; ++tagFrom) {
const auto ch = insert.at(tagFrom);
if (!IsNewline(ch)) {
break;
}
}
for (; tagTill != tagFrom; --tagTill) {
const auto ch = insert.at(tagTill - 1);
if (!IsNewline(ch)) {
break;
}
}
if (tagTill > tagFrom) {
_insertedTags.push_back({
tagFrom,
int(tagTill - tagFrom),
tag,
});
}
// Replace.
auto cursor = _inner->textCursor();
cursor.setPosition(from);
cursor.setPosition(till, QTextCursor::KeepAnchor);
auto format = _defaultCharFormat;
if (!edge.isEmpty()) {
format.setProperty(kReplaceTagId, edge);
_reverseMarkdownReplacement = true;
}
_insertedTagsAreFromMime = false;
cursor.insertText(insert, format);
_insertedTags.clear();
cursor.setCharFormat(_defaultCharFormat);
_inner->setTextCursor(cursor);
// Fire the tag to the spellchecker.
_markdownTagApplies.fire({ from, till, -1, -1, false, tag });
return true;
}
#endif
auto InputField::addMarkdownTag(TextRange range, const QString &tag)
-> TextRange {
auto current = getTextWithTagsPart(range.from, range.till);
auto filled = 0;
auto tags = TextWithTags::Tags();
if (!TextUtilities::IsSeparateTag(tag)) {
for (auto existing : current.tags) {
if (existing.offset > filled) {
tags.push_back({ filled, existing.offset - filled, tag });
}
existing.id = TagWithAddedDroppingMath(
existing.id,
tag,
_instantViewEditorTagsEnabled);
tags.push_back(std::move(existing));
filled = existing.offset + existing.length;
}
}
if (filled < current.text.size()) {
tags.push_back({ filled, int(current.text.size()) - filled, tag });
}
current.tags = TextUtilities::SimplifyTags(std::move(tags));
const auto result = insertWithTags(range, std::move(current));
// Fire the tag to the spellchecker.
_markdownTagApplies.fire(
{ result.from, result.till, -1, -1, false, tag });
return result;
}
auto InputField::insertWithTags(TextRange range, TextWithTags text)
-> TextRange {
if (text.empty() || text.tags.isEmpty()) {
finishMarkdownTagChange(range, PrepareForInsert(std::move(text)));
return range;
}
text = ShiftLeftBlockTag(std::move(text));
text = ShiftRightBlockTag(std::move(text));
auto result = range;
const auto firstTag = text.tags.front();
const auto lastTag = text.tags.back();
const auto textLength = int(text.text.size());
const auto adjustLeft = !firstTag.offset && HasBlockTag(firstTag.id);
const auto adjustRight = (lastTag.offset + lastTag.length >= textLength)
&& HasBlockTag(lastTag.id);
auto cursor = QTextCursor(document());
cursor.movePosition(QTextCursor::End);
const auto fullLength = cursor.position();
const auto goodLeft = !adjustLeft
|| !range.from
|| (document()->findBlock(range.from).position() == range.from);
const auto goodRight = !adjustRight
|| (range.till >= fullLength)
|| (document()->findBlock(range.till + 1).position()
== range.till + 1);
const auto leftEdge = goodLeft
? TextWithTags()
: getTextWithTagsPart(range.from - 1, range.from);
const auto rightEdge = goodRight
? TextWithTags()
: getTextWithTagsPart(range.till, range.till + 1);
const auto extendLeft = !leftEdge.empty()
&& IsNewline(leftEdge.text.back());
const auto extendRight = !rightEdge.empty()
&& IsNewline(rightEdge.text.front());
if (!goodLeft) {
text.text.insert(0, kHardLine);
for (auto &tag : text.tags) {
++tag.offset;
}
if (extendLeft) {
--range.from;
} else {
++result.from;
++result.till;
}
}
if (!goodRight) {
text.text.push_back(kHardLine);
if (extendRight) {
++range.till;
}
}
finishMarkdownTagChange(range, PrepareForInsert(std::move(text)));
return result;
}
void InputField::removeMarkdownTag(TextRange range, const QString &tag) {
auto current = getTextWithTagsPart(range.from, range.till);
auto tags = TagList();
for (const auto &existing : current.tags) {
const auto id = TextUtilities::TagWithRemoved(existing.id, tag);
const auto additional = (tag == kTagPre)
? kTagCode
: (tag == kTagCode)
? kTagPre
: QString();
const auto removeBlock = (IsTagPre(tag) || tag == kTagCode)
&& IsTagPre(FindBlockTag(id));
const auto use = removeBlock
? WithBlockTagRemoved(id)
: additional.isEmpty()
? id
: TextUtilities::TagWithRemoved(id, additional);
if (!use.isEmpty()) {
tags.push_back({ existing.offset, existing.length, use });
}
}
current.tags = std::move(tags);
_insertedTagsReplace = true;
finishMarkdownTagChange(range, PrepareForInsert(std::move(current)));
_insertedTagsReplace = false;
}
void InputField::finishMarkdownTagChange(
TextRange range,
const TextWithTags &textWithTags) {
auto cursor = _inner->textCursor();
cursor.beginEditBlock();
cursor.setPosition(range.from);
cursor.setPosition(range.till, QTextCursor::KeepAnchor);
_insertedTags = textWithTags.tags;
_realInsertPosition = range.from;
_realCharsAdded = textWithTags.text.size();
cursor.insertText(textWithTags.text);
cursor.setCharFormat(_defaultCharFormat);
cursor.endEditBlock();
if (!_insertedTagsDelayClear) {
_insertedTags.clear();
_realInsertPosition = -1;
}
_inner->setTextCursor(cursor);
}
bool InputField::IsValidMarkdownLink(QStringView link) {
return ::Ui::IsValidMarkdownLink(link)
&& !::Ui::IsCustomEmojiLink(link)
&& !::Ui::IsCustomDateLink(link);
}
bool InputField::IsCustomEmojiLink(QStringView link) {
return ::Ui::IsCustomEmojiLink(link);
}
bool InputField::IsCustomDateLink(QStringView link) {
return ::Ui::IsCustomDateLink(link);
}
bool InputField::IsInstantViewEditorTag(QStringView tag) {
return (tag == kTagIvMarked)
|| (tag == kTagIvSubscript)
|| (tag == kTagIvSuperscript)
|| (tag == kTagIvMath)
|| IsInstantViewAnchorLink(tag);
}
bool InputField::IsInstantViewAnchorLink(QStringView link) {
return (link.size() > 1) && link.startsWith(QChar('#'));
}
QString InputField::CustomEmojiLink(QStringView entityData) {
return MakeUniqueCustomEmojiLink(u"%1%2"_q
.arg(kCustomEmojiTagStart)
.arg(entityData));
}
QString InputField::CustomEmojiEntityData(QStringView link) {
const auto match = qthelp::regex_match(
"^(\\d+)(\\?|$)",
base::StringViewMid(link, kCustomEmojiTagStart.size()));
return match ? match->captured(1) : QString();
}
void InputField::commitMarkdownLinkEdit(
EditLinkSelection selection,
const TextWithTags &textWithTags,
const QString &link) {
if (textWithTags.text.isEmpty()
|| !IsEditableLinkTag(link, _instantViewEditorTagsEnabled)
|| !_editLinkCallback) {
return;
}
auto prepared = PrepareForInsert(textWithTags);
{
auto from = 0;
const auto till = int(prepared.text.size());
auto &tags = prepared.tags;
auto i = tags.begin();
while (from < till) {
while (i != tags.end() && i->offset <= from) {
const auto id = _instantViewEditorTagsEnabled
? TagWithoutInstantViewMath(i->id)
: i->id;
auto all = TextUtilities::SplitTags(id);
auto j = all.begin();
for (; j != all.end(); ++j) {
if (IsEditableLinkTag(
*j,
_instantViewEditorTagsEnabled)) {
*j = link;
break;
}
}
if (j == all.end()) {
all.push_back(link);
}
i->id = TextUtilities::JoinTag(all);
from = i->offset + i->length;
++i;
}
const auto tagFrom = (i == tags.end())
? till
: i->offset;
if (from < tagFrom) {
i = tags.insert(i, { from, tagFrom - from, link });
from = tagFrom;
++i;
}
}
}
_insertedTags = prepared.tags;
_insertedTagsAreFromMime = false;
auto cursor = textCursor();
const auto editData = selectionEditLinkData(selection);
cursor.setPosition(editData.from);
cursor.setPosition(editData.till, QTextCursor::KeepAnchor);
auto format = _defaultCharFormat;
_insertedTagsAreFromMime = false;
const auto text = prepared.text;
cursor.insertText(
(editData.from == editData.till) ? (text + QChar(' ')) : text,
_defaultCharFormat);
_insertedTags.clear();
_reverseMarkdownReplacement = false;
_correcting = true;
cursor.joinPreviousEditBlock();
cursor.setCharFormat(_defaultCharFormat);
cursor.endEditBlock();
_inner->setTextCursor(cursor);
_correcting = false;
}
void InputField::commitMarkdownTagEdit(
InputFieldTextRange range,
const QString &tag,
const QString &text) {
if (tag.isEmpty() || text.isEmpty()) {
return;
}
_reverseMarkdownReplacement = false;
_insertedTagsAreFromMime = false;
finishMarkdownTagChange(TextRange{
range.from,
range.till,
}, PrepareForInsert(TextWithTags{
.text = text,
.tags = { TextWithTags::Tag{
.offset = 0,
.length = int(text.size()),
.id = tag,
} },
}));
}
bool InputField::isMarkdownTagActive(const QString &tag) const {
if (tag.isEmpty()) {
return false;
}
const auto cursor = textCursor();
if (cursor.hasSelection()) {
return HasFullTextTag(getTextWithTagsSelected(), tag);
}
return TextUtilities::SplitTags(TagWithoutCustomEmoji(
cursor.charFormat().property(kTagProperty).toString())).contains(tag);
}
QString InputField::selectionMarkdownTagForToggle(const QString &tag) const {
const auto cursor = textCursor();
auto from = cursor.selectionStart();
auto till = cursor.selectionEnd();
if (from >= till) {
return QString();
}
if (document()->characterAt(from) == kHardLine) {
++from;
}
if (document()->characterAt(till - 1) == kHardLine) {
--till;
}
if (from >= till) {
return QString();
}
if (tag != kTagCode) {
return tag;
}
const auto leftForBlock = [&] {
if (from <= 0) {
return true;
}
const auto text = getTextWithTagsPart(
from - 1,
from + 1
).text;
return text.isEmpty()
|| IsNewline(text[0])
|| IsNewline(text[text.size() - 1]);
}();
const auto rightForBlock = [&] {
auto cursor = textCursor();
cursor.movePosition(QTextCursor::End);
if (till >= cursor.position()) {
return true;
}
const auto text = getTextWithTagsPart(
till - 1,
till + 1
).text;
return text.isEmpty()
|| IsNewline(text[0])
|| IsNewline(text[text.size() - 1]);
}();
return (leftForBlock && rightForBlock) ? kTagPre : kTagCode;
}
void InputField::toggleCurrentMarkdownTag(const QString &tag) {
if (tag.isEmpty()) {
return;
}
_reverseMarkdownReplacement = false;
_insertedTagsAreFromMime = false;
const auto cursor = textCursor();
if (cursor.hasSelection()) {
toggleSelectionMarkdown(tag);
Integration::Instance().textActionsUpdated();
return;
}
const auto currentTag = TagWithoutCustomEmoji(
cursor.charFormat().property(kTagProperty).toString());
const auto updatedTag = isMarkdownTagActive(tag)
? TextUtilities::TagWithRemoved(currentTag, tag)
: TagWithAddedDroppingMath(
currentTag,
tag,
_instantViewEditorTagsEnabled);
auto format = _defaultCharFormat;
format.merge(PrepareTagFormat(
_st,
updatedTag,
_instantViewEditorTagsEnabled));
_defaultCharFormat = format;
auto updatedCursor = cursor;
updatedCursor.setCharFormat(format);
setTextCursor(updatedCursor);
Integration::Instance().textActionsUpdated();
}
void InputField::clearCurrentMarkdown() {
_reverseMarkdownReplacement = false;
_insertedTagsAreFromMime = false;
const auto cursor = textCursor();
if (cursor.hasSelection()) {
clearSelectionMarkdown();
Integration::Instance().textActionsUpdated();
return;
}
auto format = _defaultCharFormat;
format.merge(PrepareTagFormat(
_st,
QString(),
_instantViewEditorTagsEnabled));
_defaultCharFormat = format;
auto updatedCursor = cursor;
updatedCursor.setCharFormat(format);
setTextCursor(updatedCursor);
Integration::Instance().textActionsUpdated();
}
bool InputField::hasCurrentMarkdownLink() const {
if (!_editLinkCallback) {
return false;
}
const auto cursor = textCursor();
const auto selection = EditLinkSelection{
.from = cursor.selectionStart(),
.till = cursor.selectionEnd(),
};
return !selectionEditLinkData(selection).link.isEmpty();
}
void InputField::editCurrentMarkdownLink() {
const auto cursor = textCursor();
editMarkdownLink({
cursor.selectionStart(),
cursor.selectionEnd(),
});
}
void InputField::toggleSelectionMarkdown(const QString &tag) {
_reverseMarkdownReplacement = false;
_insertedTagsAreFromMime = false;
const auto cursor = textCursor();
auto position = cursor.position();
auto from = cursor.selectionStart();
auto till = cursor.selectionEnd();
if (from >= till) {
return;
}
if (document()->characterAt(from) == kHardLine) {
++from;
}
if (document()->characterAt(till - 1) == kHardLine) {
--till;
}
auto range = TextRange{ from, till };
if (tag.isEmpty()) {
RemoveDocumentTags(_st, document(), from, till);
} else {
const auto useTag = selectionMarkdownTagForToggle(tag);
if (useTag.isEmpty()) {
return;
} else if (HasFullTextTag(getTextWithTagsSelected(), useTag)) {
removeMarkdownTag(range, useTag);
} else {
range = addMarkdownTag(range, useTag);
}
}
auto restorePosition = textCursor();
restorePosition.setPosition(
(position == till) ? range.from : range.till);
restorePosition.setPosition(
(position == till) ? range.till : range.from,
QTextCursor::KeepAnchor);
setTextCursor(restorePosition);
}
void InputField::clearSelectionMarkdown() {
toggleSelectionMarkdown(QString());
}
bool InputField::revertFormatReplace() {
const auto cursor = textCursor();
const auto position = cursor.position();
if (position <= 0 || cursor.hasSelection()) {
return false;
}
const auto inside = position - 1;
const auto document = _inner->document();
const auto block = document->findBlock(inside);
if (block == document->end()) {
return false;
}
for (auto i = block.begin(); !i.atEnd(); ++i) {
const auto fragment = i.fragment();
const auto fragmentStart = fragment.position();
const auto fragmentEnd = fragmentStart + fragment.length();
if (fragmentEnd <= inside) {
continue;
} else if (fragmentStart > inside || fragmentEnd != position) {
return false;
}
const auto current = fragment.charFormat();
if (current.hasProperty(kInstantReplaceWithId)) {
const auto with = current.property(kInstantReplaceWithId);
const auto string = with.toString();
if (fragment.text() != string) {
return false;
}
auto replaceCursor = cursor;
replaceCursor.setPosition(fragmentStart);
replaceCursor.setPosition(fragmentEnd, QTextCursor::KeepAnchor);
const auto what = current.property(kInstantReplaceWhatId);
auto format = _defaultCharFormat;
format.merge(PrepareTagFormat(
_st,
TagWithoutCustomEmoji(
current.property(kTagProperty).toString()),
_instantViewEditorTagsEnabled));
format.setProperty(kReplaceTagId, current.property(kReplaceTagId));
replaceCursor.insertText(what.toString(), format);
return true;
} else if (_reverseMarkdownReplacement
&& current.hasProperty(kReplaceTagId)) {
const auto tag = current.property(kReplaceTagId).toString();
if (tag.isEmpty()) {
return false;
} else if (auto test = i; !(++test).atEnd()) {
const auto format = test.fragment().charFormat();
if (format.property(kReplaceTagId).toString() == tag) {
return false;
}
} else if (auto test = block; test.next() != document->end()) {
const auto begin = test.begin();
if (begin != test.end()) {
const auto format = begin.fragment().charFormat();
if (format.property(kReplaceTagId).toString() == tag) {
return false;
}
}
}
const auto first = [&] {
auto checkBlock = block;
auto checkLast = i;
while (true) {
for (auto j = checkLast; j != checkBlock.begin();) {
--j;
const auto format = j.fragment().charFormat();
if (format.property(kReplaceTagId) != tag) {
return ++j;
}
}
if (checkBlock == document->begin()) {
return checkBlock.begin();
}
checkBlock = checkBlock.previous();
checkLast = checkBlock.end();
}
}();
const auto from = first.fragment().position();
const auto till = fragmentEnd;
auto replaceCursor = cursor;
replaceCursor.setPosition(from);
replaceCursor.setPosition(till, QTextCursor::KeepAnchor);
replaceCursor.insertText(
tag + getTextWithTagsPart(from, till).text + tag,
_defaultCharFormat);
return true;
}
return false;
}
return false;
}
bool InputField::jumpOutOfBlockByBackspace() {
auto cursor = textCursor();
if (cursor.hasSelection()) {
return false;
}
const auto position = cursor.position();
if (!position) {
return false;
}
const auto block = document()->findBlock(position);
const auto tagId = block.blockFormat().property(kQuoteFormatId);
if (block.position() != position || !HasBlockTag(tagId.toString())) {
return false;
}
cursor.setPosition(position - 1);
setTextCursor(cursor);
return true;
}
void InputField::contextMenuEventInner(QContextMenuEvent *e) {
const auto menu = _inner->createStandardContextMenu();
if (!menu) {
return;
}
addMarkdownActions(menu, e);
// The menu may be shown asynchronously: a hook (spell checking) can defer
// the show until its async work finishes. We keep a single shared barrier
// and show exactly one PopupMenu once every deferral has completed.
struct State {
int pending = 1;
std::vector<Fn<void(not_null<PopupMenu*>)>> setups;
};
const auto state = std::make_shared<State>();
const auto globalPos = e->globalPos();
const auto show = crl::guard(this, [=] {
_contextMenu = base::make_unique_q<PopupMenu>(this, menu, _st.menu);
for (const auto &setup : state->setups) {
setup(_contextMenu.get());
}
QObject::connect(_contextMenu.get(), &QObject::destroyed, [=] {
_menuShownChanges.fire(false);
});
_menuShownChanges.fire(true);
_contextMenu->popup(globalPos);
});
auto request = ContextMenuRequest{
.menu = menu,
.event = e,
.customizePopupMenu = [=](Fn<void(not_null<PopupMenu*>)> setup) {
state->setups.push_back(std::move(setup));
},
.awaitAsyncWork = [=]() -> Fn<void()> {
++state->pending;
return [=] {
if (!--state->pending) {
show();
}
};
},
};
for (const auto &hook : _contextMenuHooks) {
hook(request);
}
if (!--state->pending) {
show();
}
}
void InputField::addMarkdownActions(
not_null<QMenu*> menu,
QContextMenuEvent *e) {
if (_markdownEnabledState.disabled()) {
return;
}
auto &integration = Integration::Instance();
const auto formatting = new QAction(
integration.phraseFormattingTitle(),
menu);
addMarkdownMenuAction(menu, formatting);
const auto submenu = new QMenu(menu);
formatting->setMenu(submenu);
const auto textWithTags = getTextWithTagsSelected();
const auto &text = textWithTags.text;
const auto &tags = textWithTags.tags;
const auto hasText = !text.isEmpty();
const auto hasTags = !tags.isEmpty();
const auto disabled = (!_editLinkCallback && !hasText);
formatting->setDisabled(disabled);
if (disabled) {
return;
}
const auto add = [&](
const QString &base,
QKeySequence sequence,
bool disabled,
auto callback) {
const auto add = sequence.isEmpty()
? QString()
: QChar('\t') + sequence.toString(QKeySequence::NativeText);
const auto action = new QAction(base + add, submenu);
connect(action, &QAction::triggered, this, callback);
action->setDisabled(disabled);
submenu->addAction(action);
};
const auto addtag = [&](
const QString &base,
QKeySequence sequence,
const QString &tag) {
if (!_markdownEnabledState.enabledForTag(tag)) {
return;
}
const auto disabled = !hasText;
add(base, sequence, disabled, [=] {
toggleSelectionMarkdown(tag);
});
};
const auto addlink = [&] {
const auto selection = editLinkSelection(e);
const auto data = selectionEditLinkData(selection);
const auto base = data.link.isEmpty()
? integration.phraseFormattingLinkCreate()
: integration.phraseFormattingLinkEdit();
add(base, kEditLinkSequence, false, [=] {
editMarkdownLink(selection);
});
};
const auto addclear = [&] {
const auto disabled = !hasText || !hasTags;
add(integration.phraseFormattingClear(), kClearFormatSequence, disabled, [=] {
clearSelectionMarkdown();
});
};
if (_markdownSet == MarkdownSet::Notes) {
addtag(integration.phraseFormattingBold(), QKeySequence::Bold, kTagBold);
addtag(integration.phraseFormattingItalic(), QKeySequence::Italic, kTagItalic);
addtag(integration.phraseFormattingUnderline(), QKeySequence::Underline, kTagUnderline);
addtag(integration.phraseFormattingStrikeOut(), kStrikeOutSequence, kTagStrikeOut);
addtag(integration.phraseFormattingSpoiler(), kSpoilerSequence, kTagSpoiler);
} else {
addtag(integration.phraseFormattingBold(), QKeySequence::Bold, kTagBold);
addtag(integration.phraseFormattingItalic(), QKeySequence::Italic, kTagItalic);
addtag(integration.phraseFormattingUnderline(), QKeySequence::Underline, kTagUnderline);
addtag(integration.phraseFormattingStrikeOut(), kStrikeOutSequence, kTagStrikeOut);
addtag(integration.phraseFormattingBlockquote(), kBlockquoteSequence, kTagBlockquote);
addtag(integration.phraseFormattingMonospace(), kMonospaceSequence, kTagCode);
addtag(integration.phraseFormattingSpoiler(), kSpoilerSequence, kTagSpoiler);
if (_editLinkCallback) {
submenu->addSeparator();
addlink();
const auto dateSelection = editLinkSelection(e);
const auto dateData = selectionEditLinkData(dateSelection);
const auto overDate = IsCustomDateLink(dateData.link);
const auto dateDisabled = !hasText && !overDate;
add(integration.phraseFormattingDate(), kEditDateSequence, dateDisabled, [=] {
editMarkdownDate(dateSelection);
});
}
}
submenu->addSeparator();
addclear();
}
void InputField::addMarkdownMenuAction(
not_null<QMenu*> menu,
not_null<QAction*> action) {
const auto actions = menu->actions();
const auto before = [&] {
auto seenAfter = false;
for (const auto action : actions) {
if (seenAfter) {
return action;
} else if (action->objectName() == qstr("edit-delete")) {
seenAfter = true;
}
}
return (QAction*)nullptr;
}();
menu->insertSeparator(before);
menu->insertAction(before, action);
}
void InputField::dropEventInner(QDropEvent *e) {
_insertedTagsDelayClear = true;
_inner->QTextEdit::dropEvent(e);
_insertedTagsDelayClear = false;
_insertedTags.clear();
_realInsertPosition = -1;
window()->raise();
window()->activateWindow();
}
bool InputField::canInsertFromMimeDataInner(const QMimeData *source) const {
if (source
&& _mimeDataHook
&& _mimeDataHook(source, MimeAction::Check)) {
return true;
}
return _inner->QTextEdit::canInsertFromMimeData(source);
}
void InputField::insertFromMimeDataInner(const QMimeData *source) {
if (!source) {
return;
}
if (source
&& _mimeDataHook
&& _mimeDataHook(source, MimeAction::Insert)) {
return;
}
const auto text = [&] {
const auto textMime = TextUtilities::TagsTextMimeType();
const auto tagsMime = TextUtilities::TagsMimeType();
const auto modifiers = QGuiApplication::keyboardModifiers();
const auto plain = (modifiers & Qt::ControlModifier)
&& (modifiers & Qt::ShiftModifier);
const auto plainText = [&] {
_insertedTags.clear();
_insertedTagsAreFromMime = false;
auto result = source->text();
return result.replace(u"\r\n"_q, u"\n"_q);
};
if (plain) {
return plainText();
}
if (source->hasFormat(textMime) && source->hasFormat(tagsMime)) {
auto result = QString::fromUtf8(source->data(textMime));
_insertedTags = TextUtilities::DeserializeTags(
source->data(tagsMime),
result.size());
_insertedTagsAreFromMime = true;
return result;
}
if (source->hasHtml() && !_markdownEnabledState.disabled()) {
if (auto parsed = TextUtilities::TextWithTagsFromHtml(
source->html())) {
if (!HtmlTextMatchesPlainTextStart(
parsed->text,
source->text())) {
return plainText();
}
TrimFullCoverageTags(*parsed);
if (parsed->tags.isEmpty()) {
return plainText();
}
_insertedTags = std::move(parsed->tags);
_insertedTagsAreFromMime = false;
return std::move(parsed->text);
}
}
return plainText();
}();
auto cursor = textCursor();
if (!text.isEmpty()) {
insertWithTags(
{ cursor.selectionStart(), cursor.selectionEnd() },
{ text, _insertedTags });
}
ensureCursorVisible();
if (!_insertedTagsDelayClear) {
_insertedTags.clear();
}
}
void InputField::resizeEvent(QResizeEvent *e) {
refreshPlaceholder(_placeholderFull.current());
_inner->setGeometry(rect().marginsRemoved(
_st.textMargins + _additionalMargins + _customFontMargins));
if (_spoilerOverlay) {
_spoilerOverlay->setGeometry(_inner->rect());
}
_borderAnimationStart = width() / 2;
RpWidget::resizeEvent(e);
checkContentHeight();
}
void InputField::refreshPlaceholder(const QString &text) {
const auto margins = _st.textMargins
+ _st.placeholderMargins
+ _additionalMargins
+ _customFontMargins;
const auto availableWidth = rect().marginsRemoved(margins).width();
if (_st.placeholderScale > 0.) {
auto placeholderFont = _st.placeholderFont->f;
placeholderFont.setStyleStrategy(QFont::PreferMatch);
const auto metrics = QFontMetrics(placeholderFont);
_placeholder = metrics.elidedText(text, Qt::ElideRight, availableWidth);
_placeholderPath = QPainterPath();
if (!_placeholder.isEmpty()) {
const auto result = style::FindAdjustResult(placeholderFont);
const auto ascent = result ? result->iascent : metrics.ascent();
_placeholderPath.addText(0, ascent, placeholderFont, _placeholder);
}
} else {
_placeholder = _st.placeholderFont->elided(text, availableWidth);
}
update();
}
void InputField::setPlaceholder(
rpl::producer<QString> placeholder,
int afterSymbols) {
_placeholderFull = std::move(placeholder);
if (_placeholderAfterSymbols != afterSymbols) {
_placeholderAfterSymbols = afterSymbols;
startPlaceholderAnimation();
}
}
void InputField::setEditLinkCallback(
Fn<bool(
EditLinkSelection selection,
TextWithTags text,
QString link,
EditLinkAction action)> callback) {
_editLinkCallback = std::move(callback);
}
void InputField::setEditLanguageCallback(
Fn<void(QString now, Fn<void(QString)> save)> callback) {
_editLanguageCallback = std::move(callback);
}
void InputField::showError() {
showErrorNoFocus();
if (!hasFocus()) {
_inner->setFocus();
}
}
void InputField::showErrorNoFocus() {
setErrorShown(true);
}
void InputField::hideError() {
setErrorShown(false);
}
void InputField::setErrorShown(bool error) {
if (_error != error) {
_error = error;
_a_error.start([this] { update(); }, _error ? 0. : 1., _error ? 1. : 0., _st.duration);
startBorderAnimation();
}
}
rpl::producer<> InputField::heightChanges() const {
return _heightChanges.events();
}
rpl::producer<bool> InputField::focusedChanges() const {
return _focusedChanges.events();
}
rpl::producer<not_null<bool*>> InputField::tabbed() const {
return _tabbed.events();
}
rpl::producer<> InputField::cancelled() const {
return _cancelled.events();
}
rpl::producer<> InputField::changes() const {
return _changes.events();
}
rpl::producer<Qt::KeyboardModifiers> InputField::submits() const {
return _submits.events();
}
InputField::~InputField() = default;
// Optimization: with null page size document does not re-layout
// on each insertText / mergeCharFormat.
void PrepareFormattingOptimization(not_null<QTextDocument*> document) {
if (!document->pageSize().isNull()) {
document->setPageSize(QSizeF(0, 0));
}
}
int ComputeRealUnicodeCharactersCount(const QString &text) {
return text.size() - ranges::count(text, true, [](QChar ch) {
return ch.isHighSurrogate();
});
}
int ComputeFieldCharacterCount(not_null<InputField*> field) {
return ComputeRealUnicodeCharactersCount(field->getLastText());
}
void AddLengthLimitLabel(
not_null<InputField*> field,
int limit,
LengthLimitLabelOptions options) {
struct State {
rpl::variable<int> length;
};
constexpr auto kMinus = QChar(0x2212);
const auto parent = options.customParent
? options.customParent
: field.get();
const auto state = field->lifetime().make_state<State>();
state->length = rpl::single(
rpl::empty
) | rpl::then(field->changes()) | rpl::map(
options.customCharactersCount
? options.customCharactersCount
: [=] { return int(field->getLastText().size()); });
const auto allowExceed = std::max(limit / 2, 9);
field->setMaxLength(limit + allowExceed);
const auto threshold = options.customThreshold.value_or(
std::min(limit / 2, 9));
auto warningText = state->length.value() | rpl::map([=](int count) {
const auto left = limit - count;
return (left >= threshold)
? QString()
: (left < 0)
? (kMinus + QString::number(std::abs(left)))
: QString::number(left);
});
const auto warning = CreateChild<FlatLabel>(
parent,
std::move(warningText),
st::defaultInputFieldLimit);
const auto maxSize = st::defaultInputFieldLimit.style.font->width(
kMinus + QString::number(allowExceed));
if (parent == field) {
const auto add = maxSize - field->st().textMargins.right();
if (add > 0) {
field->setAdditionalMargins({ 0, 0, std::max(add, 0), 0 });
}
}
state->length.value() | rpl::map(
rpl::mappers::_1 > limit
) | rpl::on_next([=](bool exceeded) {
warning->setTextColorOverride(exceeded
? st::attentionButtonFg->c
: std::optional<QColor>());
}, warning->lifetime());
const auto updatePosition = options.customUpdatePosition
? options.customUpdatePosition
: [=, added = options.limitLabelTop](QSize parent, QSize label) {
// Baseline alignment.
const auto top = field->st().textMargins.top()
+ field->st().style.font->ascent
- st::defaultInputFieldLimit.style.font->ascent;
return QPoint(parent.width() - label.width(), top + added);
};
rpl::combine(
parent->sizeValue(),
warning->sizeValue()
) | rpl::on_next([warning, updatePosition](QSize a, QSize b) {
warning->move(updatePosition(a, b));
}, warning->lifetime());
warning->setAttribute(Qt::WA_TransparentForMouseEvents);
}
bool ShouldSubmit(QKeyEvent *event, InputSubmitSettings settings) {
if (event == nullptr || settings == InputSubmitSettings::None) {
return false;
}
const int key = event->key();
const bool isEnter = (key == Qt::Key_Enter || key == Qt::Key_Return);
const bool hasCtrl = event->modifiers().testFlag(Qt::ControlModifier);
switch (settings) {
case InputSubmitSettings::Enter: return isEnter && !hasCtrl;
case InputSubmitSettings::CtrlEnter: return isEnter && hasCtrl;
case InputSubmitSettings::Both: return isEnter;
case InputSubmitSettings::None:
default: return false;
}
}
} // namespace Ui