/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "tests/test_main.h" #include "base/invoke_queued.h" #include "base/integration.h" #include "ui/effects/animations.h" #include "ui/style/style_core.h" #include "ui/text/text.h" #include "ui/text/text_custom_emoji.h" #include "ui/text/text_utilities.h" #include "ui/widgets/rp_window.h" #include "ui/painter.h" #include #include #include #include #include #include #include #include namespace Test { namespace { [[nodiscard]] bool HasEntityType( const EntitiesInText &entities, EntityType type) { for (const auto &entity : entities) { if (entity.type() == type) { return true; } } return false; } [[nodiscard]] QImage MakeObjectImage( QSize size, QColor background, const QString &label) { auto image = QImage(size, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent); auto painter = QPainter(&image); auto hq = PainterHighQualityEnabler(painter); painter.setPen(Qt::NoPen); painter.setBrush(background); painter.drawRoundedRect( QRect(QPoint(), size), scale(6), scale(6)); painter.setPen(Qt::black); painter.drawText(QRect(QPoint(), size), Qt::AlignCenter, label); return image; } [[nodiscard]] int RenderTextPadding() { return scale(6); } [[nodiscard]] QImage RenderTextOffscreen( const Ui::Text::String &text, int availableWidth, std::optional selection = std::nullopt, std::span colors = {}, std::optional pen = std::nullopt) { const auto padding = RenderTextPadding(); const auto image = QImage( QSize( std::max(text.maxWidth(), availableWidth) + (2 * padding), std::max(text.countHeight(availableWidth), text.minHeight()) + (2 * padding)), QImage::Format_ARGB32_Premultiplied); auto result = image; result.fill(Qt::transparent); auto painter = QPainter(&result); if (pen) { painter.setPen(*pen); } text.draw(painter, { .position = QPoint(padding, padding), .availableWidth = availableWidth, .colors = colors, .selection = selection.value_or(TextSelection()), }); return result; } [[nodiscard]] std::optional ChangedBoundsInRect( const QImage &first, const QImage &second, QRect rect) { if (first.size() != second.size()) { return std::nullopt; } rect = rect.intersected(QRect(QPoint(), first.size())); if (rect.isEmpty()) { return std::nullopt; } auto left = rect.right(); auto top = rect.bottom(); auto right = rect.left() - 1; auto bottom = rect.top() - 1; for (auto y = rect.top(); y <= rect.bottom(); ++y) { for (auto x = rect.left(); x <= rect.right(); ++x) { if (first.pixel(x, y) == second.pixel(x, y)) { continue; } left = std::min(left, x); top = std::min(top, y); right = std::max(right, x); bottom = std::max(bottom, y); } } return (right >= left) && (bottom >= top) ? std::make_optional(QRect(QPoint(left, top), QPoint(right, bottom))) : std::nullopt; } [[nodiscard]] std::optional SymbolHitBounds( const Ui::Text::String &text, int availableWidth, int offset) { if ((availableWidth <= 0) || (offset < 0)) { return std::nullopt; } const auto padding = RenderTextPadding(); const auto height = std::max( text.countHeight(availableWidth), text.minHeight()); auto flags = Ui::Text::StateRequest::Flags(); flags |= Ui::Text::StateRequest::Flag::LookupSymbol; auto request = Ui::Text::StateRequest(); request.flags = flags; auto left = padding + availableWidth; auto top = padding + height; auto right = -1; auto bottom = -1; for (auto y = 0; y != height; ++y) { for (auto x = 0; x != availableWidth; ++x) { const auto hit = text.getState(QPoint(x, y), availableWidth, request); if (!hit.uponSymbol || (int(hit.symbol) != offset)) { continue; } left = std::min(left, x + padding); top = std::min(top, y + padding); right = std::max(right, x + padding); bottom = std::max(bottom, y + padding); } } return (right >= left) && (bottom >= top) ? std::make_optional(QRect(QPoint(left, top), QPoint(right, bottom))) : std::nullopt; } [[nodiscard]] std::optional SymbolRangeHitBounds( const Ui::Text::String &text, int availableWidth, int offset, int length) { if ((availableWidth <= 0) || (offset < 0) || (length <= 0)) { return std::nullopt; } const auto padding = RenderTextPadding(); const auto height = std::max( text.countHeight(availableWidth), text.minHeight()); auto flags = Ui::Text::StateRequest::Flags(); flags |= Ui::Text::StateRequest::Flag::LookupSymbol; auto request = Ui::Text::StateRequest(); request.flags = flags; auto left = padding + availableWidth; auto top = padding + height; auto right = -1; auto bottom = -1; const auto end = offset + length; for (auto y = 0; y != height; ++y) { for (auto x = 0; x != availableWidth; ++x) { const auto hit = text.getState(QPoint(x, y), availableWidth, request); if (!hit.uponSymbol) { continue; } const auto symbol = int(hit.symbol); if ((symbol < offset) || (symbol >= end)) { continue; } left = std::min(left, x + padding); top = std::min(top, y + padding); right = std::max(right, x + padding); bottom = std::max(bottom, y + padding); } } return (right >= left) && (bottom >= top) ? std::make_optional(QRect(QPoint(left, top), QPoint(right, bottom))) : std::nullopt; } [[nodiscard]] bool ImagesEqualInRect( const QImage &first, const QImage &second, QRect rect) { return !ChangedBoundsInRect(first, second, rect).has_value(); } [[nodiscard]] bool HasPixelColorInRect( const QImage &image, QRect rect, QRgb color) { rect = rect.intersected(QRect(QPoint(), image.size())); if (rect.isEmpty()) { return false; } for (auto y = rect.top(); y <= rect.bottom(); ++y) { for (auto x = rect.left(); x <= rect.right(); ++x) { if (image.pixel(x, y) == color) { return true; } } } return false; } [[nodiscard]] bool HasPaintedPixels(const QImage &image) { const auto bits = image.constBits(); if (!bits) { return false; } const auto count = image.sizeInBytes(); for (auto i = qsizetype(0); i != count; ++i) { if (bits[i] != 0) { return true; } } return false; } class FormulaLikeObject final : public Ui::Text::CustomEmoji { public: FormulaLikeObject(QString entityData, QString replacementText, QImage image); int width() override; QString entityData() override; std::optional vertical( const style::TextStyle &) override; QString replacementText() override; Ui::Text::CustomEmojiSemantics semantics() override; void paint(QPainter &p, const Context &context) override; void unload() override; bool ready() override; bool readyInDefaultState() override; private: QString _entityData; QString _replacementText; QImage _image; }; FormulaLikeObject::FormulaLikeObject( QString entityData, QString replacementText, QImage image) : _entityData(std::move(entityData)) , _replacementText(std::move(replacementText)) , _image(std::move(image)) { } int FormulaLikeObject::width() { return _image.width(); } QString FormulaLikeObject::entityData() { return _entityData; } std::optional FormulaLikeObject::vertical(const style::TextStyle &) { const auto height = _image.height(); const auto descent = std::max(height / 5, 1); return Ui::Text::CustomEmojiVerticalMetrics{ .ascent = height - descent, .descent = descent, }; } QString FormulaLikeObject::replacementText() { return _replacementText; } Ui::Text::CustomEmojiSemantics FormulaLikeObject::semantics() { return { .isEmoji = false, .isRealCustomEmoji = false, .exportEntity = false, .unloadPersistentAnimation = false, .allowCustomEmojiClick = false, }; } void FormulaLikeObject::paint(QPainter &p, const Context &context) { p.drawImage(context.position, _image); } void FormulaLikeObject::unload() { } bool FormulaLikeObject::ready() { return true; } bool FormulaLikeObject::readyInDefaultState() { return true; } } // namespace QString name() { return u"text"_q; } void test(not_null window, not_null body) { (void)window; const auto formulaEntityData = u"test-formula-like-object"_q; const auto formulaReplacementText = u"$\\frac{a}{b}$"_q; const auto controlEntityData = u"test-custom-emoji"_q; const auto formulaImage = MakeObjectImage( QSize(scale(64), scale(28)), QColor(32, 96, 192, 48), u"a / b"_q); const auto controlImage = MakeObjectImage( QSize(scale(24), scale(24)), QColor(224, 176, 32, 160), u"*"_q); auto context = Ui::Text::MarkedContext(); context.customEmojiFactory = [ formulaEntityData, formulaReplacementText, formulaImage, controlImage ](QStringView data, const Ui::Text::MarkedContext &) -> std::unique_ptr { if (data == formulaEntityData) { return std::make_unique( data.toString(), formulaReplacementText, formulaImage); } if (data == u"test-custom-emoji"_q) { return std::make_unique( [controlImage] { return controlImage; }, data.toString()); } return std::unique_ptr(); }; auto formulaData = TextWithEntities(); formulaData.append(u"Alpha "_q); const auto formulaPosition = formulaData.text.size(); formulaData.append(QChar::ObjectReplacementCharacter); formulaData.entities.push_back(EntityInText( EntityType::CustomEmoji, formulaPosition, 1, formulaEntityData)); formulaData.append(u" omega"_q); const auto formulaText = new Ui::Text::String(scale(64)); formulaText->setMarkedText( st::defaultTextStyle, formulaData, kMarkupTextOptions, context); const auto formulaCollapsedText = Ui::Text::String( st::defaultTextStyle, u"Alpha omega"_q, kMarkupTextOptions, scale(64)); const auto objectPlaceholderWidth = st::defaultTextStyle.font->width(u" "_q); Expects( formulaText->maxWidth() >= formulaCollapsedText.maxWidth() - objectPlaceholderWidth + formulaImage.width()); const auto expectedFormulaExport = u"Alpha $\\frac{a}{b}$ omega"_q; Expects(formulaText->toString() == expectedFormulaExport); const auto formulaMime = formulaText->toTextForMimeData(); Expects(formulaMime.expanded == expectedFormulaExport); Expects(formulaMime.rich.text == expectedFormulaExport); Expects(!HasEntityType(formulaMime.rich.entities, EntityType::CustomEmoji)); const auto formulaRich = formulaText->toTextWithEntities(); Expects(formulaRich.text == expectedFormulaExport); Expects(!HasEntityType(formulaRich.entities, EntityType::CustomEmoji)); Expects( formulaText->adjustSelection( TextSelection(uint16(formulaPosition), uint16(formulaPosition)), TextSelectType::Words) == TextSelection( uint16(formulaPosition), uint16(formulaPosition + 1))); Expects( formulaText->adjustSelection( TextSelection(uint16(formulaPosition), uint16(formulaPosition + 1)), TextSelectType::Paragraphs) == TextSelection( uint16(formulaPosition), uint16(formulaPosition + 1))); Expects(!formulaText->hasCustomEmoji()); Expects(!formulaText->isOnlyCustomEmoji()); Expects(!formulaText->isIsolatedEmoji()); auto controlData = TextWithEntities(); controlData.append(QChar::ObjectReplacementCharacter); controlData.entities.push_back(EntityInText( EntityType::CustomEmoji, 0, 1, controlEntityData)); const auto controlText = new Ui::Text::String(scale(64)); controlText->setMarkedText( st::defaultTextStyle, controlData, kMarkupTextOptions, context); Expects(controlText->hasCustomEmoji()); Expects(controlText->isOnlyCustomEmoji()); Expects(controlText->isIsolatedEmoji()); Expects( HasEntityType( controlText->toTextWithEntities().entities, EntityType::CustomEmoji)); const auto controlProbeText = u"a / b"_q; const auto controlLineSource = u"Alpha a / b omega"_q; const auto controlProbePosition = controlLineSource.indexOf(controlProbeText); Expects(controlProbePosition >= 0); const auto controlLineText = Ui::Text::String( st::defaultTextStyle, controlLineSource, kMarkupTextOptions, scale(64)); const auto selectionWidth = std::max( formulaText->maxWidth(), controlLineText.maxWidth()); const auto formulaLinesGeometry = formulaText->countLinesGeometry( selectionWidth); const auto controlLinesGeometry = controlLineText.countLinesGeometry( selectionWidth); Expects(formulaLinesGeometry.size() == 1); Expects(controlLinesGeometry.size() == 1); if ((formulaLinesGeometry.size() == 1) && (controlLinesGeometry.size() == 1)) { const auto &formulaLine = formulaLinesGeometry.front(); const auto &controlLine = controlLinesGeometry.front(); Expects(formulaLine.baseline > controlLine.baseline); Expects(formulaLine.baseline < formulaLine.bottom); Expects(controlLine.baseline < controlLine.bottom); } const auto formulaSelection = TextSelection( 0, uint16(formulaData.text.size())); const auto formulaUnselected = RenderTextOffscreen( *formulaText, selectionWidth); const auto formulaSelected = RenderTextOffscreen( *formulaText, selectionWidth, formulaSelection); const auto formulaHitBounds = SymbolHitBounds( *formulaText, selectionWidth, formulaPosition); Expects(formulaHitBounds.has_value()); const auto controlSelection = TextSelection( 0, uint16(controlLineSource.size())); const auto controlUnselected = RenderTextOffscreen( controlLineText, selectionWidth); const auto controlSelected = RenderTextOffscreen( controlLineText, selectionWidth, controlSelection); const auto controlHitBounds = SymbolRangeHitBounds( controlLineText, selectionWidth, controlProbePosition, controlProbeText.size()); Expects(controlHitBounds.has_value()); if (formulaHitBounds && controlHitBounds) { const auto formulaChangedBounds = ChangedBoundsInRect( formulaUnselected, formulaSelected, QRect( formulaHitBounds->x(), 0, formulaHitBounds->width(), formulaSelected.height())); const auto controlChangedBounds = ChangedBoundsInRect( controlUnselected, controlSelected, QRect( controlHitBounds->x(), 0, controlHitBounds->width(), controlSelected.height())); Expects(formulaChangedBounds.has_value()); Expects(controlChangedBounds.has_value()); if (formulaChangedBounds && controlChangedBounds) { Expects( formulaChangedBounds->top() <= controlChangedBounds->top()); Expects( formulaChangedBounds->bottom() >= controlChangedBounds->bottom()); Expects( (formulaChangedBounds->top() < controlChangedBounds->top()) || (formulaChangedBounds->bottom() > controlChangedBounds->bottom())); } } auto leadingFormulaData = TextWithEntities(); leadingFormulaData.append(QChar::ObjectReplacementCharacter); leadingFormulaData.entities.push_back(EntityInText( EntityType::CustomEmoji, 0, 1, formulaEntityData)); const auto leadingFormulaText = Ui::Text::String( st::defaultTextStyle, leadingFormulaData, kMarkupTextOptions, scale(32), context); const auto leadingFormulaRender = RenderTextOffscreen( leadingFormulaText, std::max(formulaImage.width() / 2, 1)); Expects(HasPaintedPixels(leadingFormulaRender)); auto skipOnlyText = Ui::Text::String( st::defaultTextStyle, u""_q, kDefaultTextOptions, scale(32)); const auto skipBlockWidth = scale(36); const auto skipBlockHeight = scale(7); Expects(skipOnlyText.updateSkipBlock(skipBlockWidth, skipBlockHeight)); Expects(skipOnlyText.countHeight(skipBlockWidth * 2) == skipBlockHeight); Expects( skipOnlyText.countDimensions(Ui::Text::SimpleGeometry( skipBlockWidth * 2, 0, 0, false)).height == skipBlockHeight); const auto colorizedText = u"Colorized link span"_q; const auto colorizedWidth = scale(280); const auto plainColorizedText = Ui::Text::String( st::defaultTextStyle, colorizedText, kMarkupTextOptions, scale(96)); const auto emptyColorizedText = Ui::Text::String( st::defaultTextStyle, Ui::Text::Colorized(colorizedText), kMarkupTextOptions, scale(96)); const auto emptyColorizedRender = RenderTextOffscreen( emptyColorizedText, colorizedWidth); const auto colorizedBoundsRect = QRect( QPoint(), emptyColorizedRender.size()); const auto linkPenRender = RenderTextOffscreen( plainColorizedText, colorizedWidth, std::nullopt, {}, st::defaultTextPalette.linkFg->p); const auto plainColorizedRender = RenderTextOffscreen( plainColorizedText, colorizedWidth); Expects(ImagesEqualInRect( emptyColorizedRender, linkPenRender, colorizedBoundsRect)); Expects(!ImagesEqualInRect( emptyColorizedRender, plainColorizedRender, colorizedBoundsRect)); const auto &specialFg = st::defaultTextPalette.linkFg; const auto &specialBg = st::defaultTextPalette.markBg; auto specialColors = std::vector{ Ui::Text::SpecialColor{ &specialFg->p, &specialFg->p, &specialBg->b, &specialBg->b }, }; const auto explicitColorizedNoBgText = Ui::Text::String( st::defaultTextStyle, Ui::Text::Colorized(colorizedText, 1, 0), kMarkupTextOptions, scale(96)); const auto explicitColorizedBgText = Ui::Text::String( st::defaultTextStyle, Ui::Text::Colorized(colorizedText, 1, 1), kMarkupTextOptions, scale(96)); const auto explicitColorizedNoBgRender = RenderTextOffscreen( explicitColorizedNoBgText, colorizedWidth, std::nullopt, specialColors); const auto explicitColorizedBgRender = RenderTextOffscreen( explicitColorizedBgText, colorizedWidth, std::nullopt, specialColors); const auto specialPenRender = RenderTextOffscreen( plainColorizedText, colorizedWidth, std::nullopt, {}, specialFg->p); const auto colorizedSymbolBounds = SymbolRangeHitBounds( explicitColorizedBgText, colorizedWidth, 0, colorizedText.size()); Expects(ImagesEqualInRect( explicitColorizedNoBgRender, specialPenRender, colorizedBoundsRect)); Expects(colorizedSymbolBounds.has_value()); if (colorizedSymbolBounds) { Expects(!ImagesEqualInRect( explicitColorizedBgRender, explicitColorizedNoBgRender, *colorizedSymbolBounds)); Expects(HasPixelColorInRect( explicitColorizedBgRender, *colorizedSymbolBounds, specialBg->c.rgba())); Expects(!HasPixelColorInRect( explicitColorizedNoBgRender, *colorizedSymbolBounds, specialBg->c.rgba())); } body->paintRequest() | rpl::on_next([=](QRect clip) { auto p = QPainter(body); p.fillRect(clip, QColor(255, 255, 255)); const auto left = scale(24); const auto top = scale(24); const auto width = body->width() - (2 * left); formulaText->draw(p, { .position = QPoint(left, top), .availableWidth = width, }); controlText->draw(p, { .position = QPoint( left, top + formulaText->countHeight(width) + scale(20)), .availableWidth = width, }); }, body->lifetime()); } } // namespace Test