diff --git a/CMakeLists.txt b/CMakeLists.txt index c4970a7..8fac166 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -230,6 +230,8 @@ PRIVATE ui/rect_part.h ui/round_rect.cpp ui/round_rect.h + ui/spoiler_click_handler.cpp + ui/spoiler_click_handler.h ui/rp_widget.cpp ui/rp_widget.h ui/ui_utility.cpp diff --git a/ui/basic.style b/ui/basic.style index 5a79615..57d56d0 100644 --- a/ui/basic.style +++ b/ui/basic.style @@ -15,6 +15,9 @@ TextPalette { selectMonoFg: color; selectOverlay: color; linkAlwaysActive: int; + spoilerBg: color; + spoilerActiveBg: color; + spoilerActiveFg: color; } TextStyle { @@ -44,6 +47,9 @@ defaultTextPalette: TextPalette { selectLinkFg: historyLinkInFgSelected; selectMonoFg: msgInMonoFgSelected; selectOverlay: msgSelectOverlay; + spoilerBg: msgInDateFg; + spoilerActiveBg: msgInDateFg; + spoilerActiveFg: msgInBg; } defaultTextStyle: TextStyle { font: normalFont; @@ -139,6 +145,9 @@ inTextPalette: TextPalette(defaultTextPalette) { selectLinkFg: historyLinkInFgSelected; selectMonoFg: msgInMonoFgSelected; selectOverlay: msgSelectOverlay; + spoilerBg: msgInDateFg; + spoilerActiveBg: msgInDateFg; + spoilerActiveFg: msgInBg; } inTextPaletteSelected: TextPalette(inTextPalette) { linkFg: historyLinkInFgSelected; @@ -152,6 +161,9 @@ outTextPalette: TextPalette(defaultTextPalette) { selectLinkFg: historyLinkOutFgSelected; selectMonoFg: msgOutMonoFgSelected; selectOverlay: msgSelectOverlay; + spoilerBg: msgOutDateFg; + spoilerActiveBg: msgOutDateFg; + spoilerActiveFg: msgOutBg; } outTextPaletteSelected: TextPalette(outTextPalette) { linkFg: historyLinkOutFgSelected; diff --git a/ui/colors.palette b/ui/colors.palette index a4b1a40..9295e2d 100644 --- a/ui/colors.palette +++ b/ui/colors.palette @@ -55,7 +55,7 @@ menuBgOver: windowBgOver; // default popup menu item background with mouse over menuBgRipple: windowBgRipple; // default popup menu item ripple effect menuIconFg: #a8a8a8; // default popup menu item icon (like main menu) menuIconFgOver: #999999; // default popup menu item icon with mouse over -menuSubmenuArrowFg: #373737; // default popup menu submenu arrow icon (like in message field context menu in case of RTL system language) +menuSubmenuArrowFg: #666B72; // default popup menu submenu arrow icon (like in message field context menu in case of RTL system language) menuFgDisabled: #cccccc; // default popup menu item disabled text (like unavailable items in message field context menu) menuSeparatorFg: #f1f1f1; // default popup menu separator (like in message field context menu) diff --git a/ui/integration.cpp b/ui/integration.cpp index e5731d2..fc0ca14 100644 --- a/ui/integration.cpp +++ b/ui/integration.cpp @@ -144,4 +144,8 @@ QString Integration::phraseFormattingMonospace() { return "Monospace"; } +QString Integration::phraseFormattingSpoiler() { + return "Spoiler"; +} + } // namespace Ui diff --git a/ui/integration.h b/ui/integration.h index 25c56fb..07c9c56 100644 --- a/ui/integration.h +++ b/ui/integration.h @@ -76,6 +76,7 @@ public: [[nodiscard]] virtual QString phraseFormattingUnderline(); [[nodiscard]] virtual QString phraseFormattingStrikeOut(); [[nodiscard]] virtual QString phraseFormattingMonospace(); + [[nodiscard]] virtual QString phraseFormattingSpoiler(); }; diff --git a/ui/platform/linux/ui_utility_linux.cpp b/ui/platform/linux/ui_utility_linux.cpp index 2a7fb2d..f55ed32 100644 --- a/ui/platform/linux/ui_utility_linux.cpp +++ b/ui/platform/linux/ui_utility_linux.cpp @@ -7,10 +7,8 @@ #include "ui/platform/linux/ui_utility_linux.h" #include "base/platform/base_platform_info.h" -#include "base/debug_log.h" #include "ui/platform/linux/ui_linux_wayland_integration.h" #include "base/const_string.h" -#include "base/flat_set.h" #ifndef DESKTOP_APP_DISABLE_DBUS_INTEGRATION #include "base/platform/linux/base_linux_glibmm_helper.h" @@ -23,10 +21,8 @@ #endif // !DESKTOP_APP_DISABLE_X11_INTEGRATION #include -#include #include #include -#include namespace Ui { namespace Platform { @@ -132,7 +128,7 @@ std::optional XCBCurrentWorkspace() { return std::nullopt; } - const auto root = base::Platform::XCB::GetRootWindowFromQt(); + const auto root = base::Platform::XCB::GetRootWindow(connection); if (!root.has_value()) { return std::nullopt; } @@ -220,7 +216,7 @@ std::optional XCBIsOverlapped( return std::nullopt; } - const auto root = base::Platform::XCB::GetRootWindowFromQt(); + const auto root = base::Platform::XCB::GetRootWindow(connection); if (!root.has_value()) { return std::nullopt; } @@ -345,7 +341,7 @@ bool ShowXCBWindowMenu(QWindow *window) { return false; } - const auto root = base::Platform::XCB::GetRootWindowFromQt(); + const auto root = base::Platform::XCB::GetRootWindow(connection); if (!root.has_value()) { return false; } @@ -431,23 +427,36 @@ bool TranslucentWindowsSupported(QPoint globalPosition) { return true; } +#ifndef DESKTOP_APP_DISABLE_X11_INTEGRATION if (::Platform::IsX11()) { - if (const auto native = QGuiApplication::platformNativeInterface()) { - if (const auto screen = QGuiApplication::screenAt(globalPosition)) { - if (native->nativeResourceForScreen(QByteArray("compositingEnabled"), screen)) { - return true; - } - const auto index = QGuiApplication::screens().indexOf(screen); - static auto WarnedAbout = base::flat_set(); - if (!WarnedAbout.contains(index)) { - WarnedAbout.emplace(index); - LOG(("WARNING: Compositing is disabled for screen index %1 (for position %2,%3)").arg(index).arg(globalPosition.x()).arg(globalPosition.y())); - } - } else { - LOG(("WARNING: Could not get screen for position %1,%2").arg(globalPosition.x()).arg(globalPosition.y())); - } + const auto connection = base::Platform::XCB::GetConnectionFromQt(); + if (!connection) { + return false; } + + const auto atom = base::Platform::XCB::GetAtom( + connection, + "_NET_WM_CM_S0"); + + if (!atom) { + return false; + } + + const auto cookie = xcb_get_selection_owner(connection, *atom); + + const auto result = base::Platform::XCB::MakeReplyPointer( + xcb_get_selection_owner_reply( + connection, + cookie, + nullptr)); + + if (!result) { + return false; + } + + return result->owner; } +#endif // !DESKTOP_APP_DISABLE_X11_INTEGRATION return false; } diff --git a/ui/platform/win/ui_window_win.cpp b/ui/platform/win/ui_window_win.cpp index b48948e..c0d78dd 100644 --- a/ui/platform/win/ui_window_win.cpp +++ b/ui/platform/win/ui_window_win.cpp @@ -740,16 +740,7 @@ HWND GetWindowHandle(not_null widget) { } HWND GetWindowHandle(not_null window) { - if (!window->winId()) { - window->create(); - } - - const auto native = QGuiApplication::platformNativeInterface(); - Assert(native != nullptr); - - return static_cast(native->nativeResourceForWindow( - QByteArrayLiteral("handle"), - window)); + return reinterpret_cast(window->winId()); } void SendWMPaintForce(not_null widget) { diff --git a/ui/spoiler_click_handler.cpp b/ui/spoiler_click_handler.cpp new file mode 100644 index 0000000..ccb16eb --- /dev/null +++ b/ui/spoiler_click_handler.cpp @@ -0,0 +1,40 @@ +// 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/spoiler_click_handler.h" + +#include "ui/effects/animation_value.h" +#include "ui/text/text_entity.h" + +ClickHandler::TextEntity SpoilerClickHandler::getTextEntity() const { + return { EntityType::Spoiler }; +} + +void SpoilerClickHandler::onClick(ClickContext context) const { + if (!_shown) { + const auto nonconst = const_cast(this); + nonconst->_shown = true; + } +} + +bool SpoilerClickHandler::shown() const { + return _shown; +} + +crl::time SpoilerClickHandler::startMs() const { + return _startMs; +} + +void SpoilerClickHandler::setStartMs(crl::time value) { + if (anim::Disabled()) { + return; + } + _startMs = value; +} + +void SpoilerClickHandler::setShown(bool value) { + _shown = value; +} diff --git a/ui/spoiler_click_handler.h b/ui/spoiler_click_handler.h new file mode 100644 index 0000000..bacaf68 --- /dev/null +++ b/ui/spoiler_click_handler.h @@ -0,0 +1,30 @@ +// 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 +// +#pragma once + +#include "ui/click_handler.h" + +enum class EntityType : uchar; + +class SpoilerClickHandler : public ClickHandler { +public: + SpoilerClickHandler() = default; + + TextEntity getTextEntity() const override; + + void onClick(ClickContext context) const override; + + [[nodiscard]] bool shown() const; + void setShown(bool value); + [[nodiscard]] crl::time startMs() const; + void setStartMs(crl::time value); + +private: + bool _shown = false; + crl::time _startMs = 0; + +}; diff --git a/ui/text/text.cpp b/ui/text/text.cpp index 9df1668..795d0e3 100644 --- a/ui/text/text.cpp +++ b/ui/text/text.cpp @@ -11,6 +11,9 @@ #include "ui/text/text_isolated_emoji.h" #include "ui/emoji_config.h" #include "ui/integration.h" +#include "ui/round_rect.h" +#include "ui/image/image_prepare.h" +#include "ui/spoiler_click_handler.h" #include "base/platform/base_platform_info.h" #include "base/qt_adapters.h" @@ -25,6 +28,7 @@ namespace { constexpr auto kStringLinkIndexShift = uint16(0x8000); constexpr auto kMaxDiacAfterSymbol = 2; +constexpr auto kSelectedSpoilerOpacity = 0.5; Qt::LayoutDirection StringDirection( const QString &str, @@ -195,6 +199,18 @@ QString textcmdStopSemibold() { return result.append(TextCommand).append(QChar(TextCommandNoSemibold)).append(TextCommand); } +QString textcmdStartSpoiler() { + QString result; + result.reserve(3); + return result.append(TextCommand).append(QChar(TextCommandSpoiler)).append(TextCommand); +} + +QString textcmdStopSpoiler() { + QString result; + result.reserve(3); + return result.append(TextCommand).append(QChar(TextCommandNoSpoiler)).append(TextCommand); +} + const QChar *textSkipCommand(const QChar *from, const QChar *end, bool canLink) { const QChar *result = from + 1; if (*from != TextCommand || result >= end) return from; @@ -212,6 +228,8 @@ const QChar *textSkipCommand(const QChar *from, const QChar *end, bool canLink) case TextCommandNoItalic: case TextCommandUnderline: case TextCommandNoUnderline: + case TextCommandSpoiler: + case TextCommandNoSpoiler: break; case TextCommandLinkIndex: @@ -273,13 +291,20 @@ private: class StartedEntity { public: explicit StartedEntity(TextBlockFlags flags); - explicit StartedEntity(uint16 lnkIndex); + explicit StartedEntity(uint16 index, bool isLnk = true); std::optional flags() const; std::optional lnkIndex() const; + std::optional spoilerIndex() const; private: - int _value = 0; + enum class Type { + Flags, + Link, + Spoiler, + }; + const int _value = 0; + const Type _type; }; @@ -334,15 +359,18 @@ private: const bool _checkTilde = false; // do we need a special text block for tilde symbol std::vector _links; + std::vector _spoilers; base::flat_map< const QChar*, std::vector> _startedEntities; uint16 _maxLnkIndex = 0; + uint16 _maxSpoilerIndex = 0; // current state int32 _flags = 0; uint16 _lnkIndex = 0; + uint16 _spoilerIndex = 0; EmojiPtr _emoji = nullptr; // current emoji, if current word is an emoji, or zero int32 _blockStart = 0; // offset in result, from which current parsed block is started int32 _diacs = 0; // diac chars skipped without good char @@ -357,23 +385,36 @@ private: }; -Parser::StartedEntity::StartedEntity(TextBlockFlags flags) : _value(flags) { +Parser::StartedEntity::StartedEntity(TextBlockFlags flags) +: _value(flags) +, _type(Type::Flags) { Expects(_value >= 0 && _value < int(kStringLinkIndexShift)); } -Parser::StartedEntity::StartedEntity(uint16 lnkIndex) : _value(lnkIndex) { - Expects(_value >= kStringLinkIndexShift); +Parser::StartedEntity::StartedEntity(uint16 index, bool isLnk) +: _value(index) +, _type(isLnk ? Type::Link : Type::Spoiler) { + Expects((_type == Type::Link) + ? (_value >= kStringLinkIndexShift) + : (_value < kStringLinkIndexShift)); } std::optional Parser::StartedEntity::flags() const { - if (_value < int(kStringLinkIndexShift)) { + if (_value < int(kStringLinkIndexShift) && (_type == Type::Flags)) { return TextBlockFlags(_value); } return std::nullopt; } std::optional Parser::StartedEntity::lnkIndex() const { - if (_value >= int(kStringLinkIndexShift)) { + if (_value >= int(kStringLinkIndexShift) && (_type == Type::Link)) { + return uint16(_value); + } + return std::nullopt; +} + +std::optional Parser::StartedEntity::spoilerIndex() const { + if (_value < int(kStringLinkIndexShift) && (_type == Type::Spoiler)) { return uint16(_value); } return std::nullopt; @@ -437,6 +478,9 @@ void Parser::createBlock(int32 skipBack) { if (_lnkIndex < kStringLinkIndexShift && _lnkIndex > _maxLnkIndex) { _maxLnkIndex = _lnkIndex; } + if (_spoilerIndex > _maxSpoilerIndex) { + _maxSpoilerIndex = _spoilerIndex; + } int32 len = int32(_t->_text.size()) + skipBack - _blockStart; if (len > 0) { @@ -450,13 +494,13 @@ void Parser::createBlock(int32 skipBack) { } _lastSkipped = false; if (_emoji) { - _t->_blocks.push_back(Block::Emoji(_t->_st->font, _t->_text, _blockStart, len, _flags, _lnkIndex, _emoji)); + _t->_blocks.push_back(Block::Emoji(_t->_st->font, _t->_text, _blockStart, len, _flags, _lnkIndex, _spoilerIndex, _emoji)); _emoji = nullptr; _lastSkipped = true; } else if (newline) { - _t->_blocks.push_back(Block::Newline(_t->_st->font, _t->_text, _blockStart, len, _flags, _lnkIndex)); + _t->_blocks.push_back(Block::Newline(_t->_st->font, _t->_text, _blockStart, len, _flags, _lnkIndex, _spoilerIndex)); } else { - _t->_blocks.push_back(Block::Text(_t->_st->font, _t->_text, _t->_minResizeWidth, _blockStart, len, _flags, _lnkIndex)); + _t->_blocks.push_back(Block::Text(_t->_st->font, _t->_text, _t->_minResizeWidth, _blockStart, len, _flags, _lnkIndex, _spoilerIndex)); } _blockStart += len; blockCreated(); @@ -466,7 +510,7 @@ void Parser::createBlock(int32 skipBack) { void Parser::createSkipBlock(int32 w, int32 h) { createBlock(); _t->_text.push_back('_'); - _t->_blocks.push_back(Block::Skip(_t->_st->font, _t->_text, _blockStart++, w, h, _lnkIndex)); + _t->_blocks.push_back(Block::Skip(_t->_st->font, _t->_text, _blockStart++, w, h, _lnkIndex, _spoilerIndex)); blockCreated(); } @@ -509,6 +553,11 @@ void Parser::finishEntities() { createBlock(); _lnkIndex = 0; } + } else if (const auto spoilerIndex = list.back().spoilerIndex()) { + if (_spoilerIndex == *spoilerIndex && (_spoilerIndex != 0)) { + createBlock(); + _spoilerIndex = 0; + } } list.pop_back(); } @@ -594,6 +643,16 @@ bool Parser::checkEntities() { _flags |= flags; _startedEntities[entityEnd].emplace_back(flags); } + } else if (entityType == EntityType::Spoiler) { + createBlock(); + + _spoilers.push_back(EntityLinkData{ + .data = QString::number(_spoilers.size() + 1), + .type = entityType, + }); + _spoilerIndex = _spoilers.size(); + + _startedEntities[entityEnd].emplace_back(_spoilerIndex, false); } ++_waitingEntity; @@ -738,6 +797,24 @@ bool Parser::readCommand() { _lnkIndex = kStringLinkIndexShift + _links.size(); } break; + case TextCommandSpoiler: { + if (!_spoilerIndex) { + createBlock(); + _spoilers.push_back(EntityLinkData{ + .data = QString::number(_spoilers.size() + 1), + .type = EntityType::Spoiler, + }); + _spoilerIndex = _spoilers.size(); + } + } break; + + case TextCommandNoSpoiler: + if (_spoilerIndex == _spoilers.size()) { + createBlock(); + _spoilerIndex = 0; + } + break; + case TextCommandSkipBlock: createSkipBlock(_ptr->unicode(), (_ptr + 1)->unicode()); break; @@ -920,7 +997,16 @@ void Parser::checkForElidedSkipBlock() { void Parser::finalize(const TextParseOptions &options) { _t->_links.resize(_maxLnkIndex); + _t->_spoilers.resize(_maxSpoilerIndex); for (auto &block : _t->_blocks) { + const auto spoilerIndex = block->spoilerIndex(); + if (spoilerIndex) { + _t->_spoilers.resize(spoilerIndex); + const auto handler = (options.flags & TextParseLinks) + ? std::make_shared() + : nullptr; + _t->setSpoiler(spoilerIndex, std::move(handler)); + } const auto shiftedIndex = block->lnkIndex(); if (shiftedIndex <= kStringLinkIndexShift) { continue; @@ -941,6 +1027,7 @@ void Parser::finalize(const TextParseOptions &options) { } } _t->_links.squeeze(); + _t->_spoilers.squeeze(); _t->_blocks.squeeze(); _t->_text.squeeze(); } @@ -1593,8 +1680,14 @@ private: TextBlockType _type = currentBlock->type(); if (!_p && _lookupX >= x && _lookupX < x + si.width) { // _lookupRequest if (_lookupLink) { - if (currentBlock->lnkIndex() && _lookupY >= _y + _yDelta && _lookupY < _y + _yDelta + _fontHeight) { - _lookupResult.link = _t->_links.at(currentBlock->lnkIndex() - 1); + if (_lookupY >= _y + _yDelta && _lookupY < _y + _yDelta + _fontHeight) { + const auto spoilerLink = _t->spoilerLink(currentBlock->spoilerIndex()); + const auto resultLink = (spoilerLink || !currentBlock->lnkIndex()) + ? spoilerLink + : _t->_links.at(currentBlock->lnkIndex() - 1); + if (resultLink) { + _lookupResult.link = resultLink; + } } } if (_type != TextBlockTSkip) { @@ -1649,29 +1742,65 @@ private: if (rtl) { glyphX += spacesWidth; } + struct { + QFixed from; + QFixed to; + } fillSelect; + struct { + QFixed from; + QFixed width; + } fillSpoiler; if (_localFrom + si.position < _selection.to) { auto chFrom = _str + currentBlock->from(); auto chTo = chFrom + ((nextBlock ? nextBlock->from() : _t->_text.size()) - currentBlock->from()); if (_localFrom + si.position >= _selection.from) { // could be without space if (chTo == chFrom || (chTo - 1)->unicode() != QChar::Space || _selection.to >= (chTo - _str)) { - fillSelectRange(x, x + si.width); + fillSelect = { x, x + si.width }; } else { // or with space - fillSelectRange(glyphX, glyphX + currentBlock->f_width()); + fillSelect = { glyphX, glyphX + currentBlock->f_width() }; } } else if (chTo > chFrom && (chTo - 1)->unicode() == QChar::Space && (chTo - 1 - _str) >= _selection.from) { if (rtl) { // rtl space only - fillSelectRange(x, glyphX); + fillSelect = { x, glyphX }; } else { // ltr space only - fillSelectRange(x + currentBlock->f_width(), x + si.width); + fillSelect = { x + currentBlock->f_width(), x + si.width }; } } } - Emoji::Draw( - *_p, - static_cast(currentBlock)->_emoji, - Emoji::GetSizeNormal(), - (glyphX + st::emojiPadding).toInt(), - _y + _yDelta + emojiY); + const auto hasSpoiler = _background.color && + (_background.inFront || _background.startMs); + if (hasSpoiler) { + fillSpoiler = { x, si.width }; + } + const auto spoilerOpacity = hasSpoiler + ? fillSpoilerOpacity() + : 0.; + const auto hasSelect = fillSelect.to != QFixed(); + if (hasSelect) { + fillSelectRange(fillSelect.from, fillSelect.to); + } + const auto opacity = _p->opacity(); + if (spoilerOpacity < 1.) { + if (hasSpoiler) { + _p->setOpacity(opacity * (1. - spoilerOpacity)); + } + Emoji::Draw( + *_p, + static_cast(currentBlock)->_emoji, + Emoji::GetSizeNormal(), + (glyphX + st::emojiPadding).toInt(), + _y + _yDelta + emojiY); + } + if (hasSpoiler) { + _p->setOpacity(opacity * spoilerOpacity); + fillSpoilerRange( + fillSpoiler.from, + fillSpoiler.width, + blockIndex, + currentBlock->from(), + (nextBlock ? nextBlock->from() : _t->_text.size())); + _p->setOpacity(opacity); + } // } else if (_p && currentBlock->type() == TextBlockSkip) { // debug // _p->fillRect(QRect(x.toInt(), _y, currentBlock->width(), static_cast(currentBlock)->height()), QColor(0, 0, 0, 32)); } @@ -1699,8 +1828,14 @@ private: if (!_p && _lookupX >= x && _lookupX < x + itemWidth) { // _lookupRequest if (_lookupLink) { - if (currentBlock->lnkIndex() && _lookupY >= _y + _yDelta && _lookupY < _y + _yDelta + _fontHeight) { - _lookupResult.link = _t->_links.at(currentBlock->lnkIndex() - 1); + if (_lookupY >= _y + _yDelta && _lookupY < _y + _yDelta + _fontHeight) { + const auto spoilerLink = _t->spoilerLink(currentBlock->spoilerIndex()); + const auto resultLink = (spoilerLink || !currentBlock->lnkIndex()) + ? spoilerLink + : _t->_links.at(currentBlock->lnkIndex() - 1); + if (resultLink) { + _lookupResult.link = resultLink; + } } } _lookupResult.uponSymbol = true; @@ -1801,29 +1936,50 @@ private: selectedRect = QRect(selX.toInt(), _y + _yDelta, (selX + selWidth).toInt() - selX.toInt(), _fontHeight); fillSelectRange(selX, selX + selWidth); } - if (Q_UNLIKELY(hasSelected)) { - if (Q_UNLIKELY(hasNotSelected)) { - auto clippingEnabled = _p->hasClipping(); - auto clippingRegion = _p->clipRegion(); - _p->setClipRect(selectedRect, Qt::IntersectClip); - _p->setPen(*_currentPenSelected); - _p->drawTextItem(QPointF(x.toReal(), textY), gf); - auto externalClipping = clippingEnabled ? clippingRegion : QRegion(QRect((_x - _w).toInt(), _y - _lineHeight, (_x + 2 * _w).toInt(), _y + 2 * _lineHeight)); - _p->setClipRegion(externalClipping - selectedRect); - _p->setPen(*_currentPen); - _p->drawTextItem(QPointF(x.toReal(), textY), gf); - if (clippingEnabled) { - _p->setClipRegion(clippingRegion); + const auto hasSpoiler = (_background.inFront || _background.startMs); + const auto spoilerOpacity = hasSpoiler + ? fillSpoilerOpacity() + : 0.; + const auto opacity = _p->opacity(); + if (spoilerOpacity < 1.) { + if (hasSpoiler) { + _p->setOpacity(opacity * (1. - spoilerOpacity)); + } + if (Q_UNLIKELY(hasSelected)) { + if (Q_UNLIKELY(hasNotSelected)) { + auto clippingEnabled = _p->hasClipping(); + auto clippingRegion = _p->clipRegion(); + _p->setClipRect(selectedRect, Qt::IntersectClip); + _p->setPen(*_currentPenSelected); + _p->drawTextItem(QPointF(x.toReal(), textY), gf); + auto externalClipping = clippingEnabled ? clippingRegion : QRegion(QRect((_x - _w).toInt(), _y - _lineHeight, (_x + 2 * _w).toInt(), _y + 2 * _lineHeight)); + _p->setClipRegion(externalClipping - selectedRect); + _p->setPen(*_currentPen); + _p->drawTextItem(QPointF(x.toReal(), textY), gf); + if (clippingEnabled) { + _p->setClipRegion(clippingRegion); + } else { + _p->setClipping(false); + } } else { - _p->setClipping(false); + _p->setPen(*_currentPenSelected); + _p->drawTextItem(QPointF(x.toReal(), textY), gf); } } else { - _p->setPen(*_currentPenSelected); + _p->setPen(*_currentPen); _p->drawTextItem(QPointF(x.toReal(), textY), gf); } - } else { - _p->setPen(*_currentPen); - _p->drawTextItem(QPointF(x.toReal(), textY), gf); + } + + if (hasSpoiler) { + _p->setOpacity(opacity * spoilerOpacity); + fillSpoilerRange( + x, + itemWidth, + blockIndex, + _localFrom + itemStart, + _localFrom + itemEnd); + _p->setOpacity(opacity); } } @@ -1837,6 +1993,91 @@ private: _p->fillRect(left, _y + _yDelta, width, _fontHeight, _textPalette->selectBg); } + float fillSpoilerOpacity() { + if (!_background.startMs) { + return 1.; + } + const auto progress = float64(crl::now() - _background.startMs) + / st::fadeWrapDuration; + if ((progress > 1.) && _background.spoilerIndex) { + const auto link = _t->_spoilers.at(_background.spoilerIndex - 1); + if (link) { + link->setStartMs(0); + } + } + return (1. - std::min(progress, 1.)); + } + + void fillSpoilerRange( + QFixed x, + QFixed width, + int currentBlockIndex, + int positionFrom, + int positionTill) { + if (!_background.color) { + return; + } + const auto elideOffset = (_indexOfElidedBlock == currentBlockIndex) + ? (_elideRemoveFromEnd + _f->elidew) + : 0; + const auto &cache = _background.inFront + ? _t->_spoilerCache + : _t->_spoilerShownCache; + const auto cornerWidth = cache.corners[0].width() + / style::DevicePixelRatio(); + const auto useWidth = (x + width).toInt() - x.toInt(); + const auto rect = QRect( + x.toInt(), + _y + _yDelta, + std::max(useWidth - elideOffset, cornerWidth * 2), + _fontHeight); + if (!rect.isValid()) { + return; + } + + const auto parts = [&] { + const auto blockIndex = currentBlockIndex - 1; + const auto block = _t->_blocks[blockIndex].get(); + const auto nextBlock = (blockIndex + 1 < _t->_blocks.size()) + ? _t->_blocks[blockIndex + 1].get() + : nullptr; + const auto blockEnd = nextBlock ? nextBlock->from() : _t->_text.size(); + const auto now = block->spoilerIndex(); + const auto was = (positionFrom > block->from()) + ? now + : (blockIndex > 0) + ? _t->_blocks[blockIndex - 1]->spoilerIndex() + : 0; + const auto will = (positionTill < blockEnd) + ? now + : nextBlock + ? nextBlock->spoilerIndex() + : 0; + return RectPart::None + | ((now != was) ? (RectPart::FullLeft) : RectPart::None) + | ((now != will) ? (RectPart::FullRight) : RectPart::None); + }(); + + if (parts != RectPart::None) { + DrawRoundedRect( + *_p, + rect, + *_background.color, + cache.corners, + parts); + } + const auto hasLeft = (parts & RectPart::Left) != 0; + const auto hasRight = (parts & RectPart::Right) != 0; + _p->fillRect( + rect.left() + (hasLeft ? cornerWidth : 0), + rect.top(), + rect.width() + - (hasRight ? cornerWidth : 0) + - (hasLeft ? cornerWidth : 0), + rect.height(), + *_background.color); + } + void elideSaveBlock(int32 blockIndex, const AbstractBlock *&_endBlock, int32 elideStart, int32 elideWidth) { if (_elideSavedBlock) { restoreAfterElided(); @@ -1845,7 +2086,7 @@ private: _elideSavedIndex = blockIndex; auto mutableText = const_cast(_t); _elideSavedBlock = std::move(mutableText->_blocks[blockIndex]); - mutableText->_blocks[blockIndex] = Block::Text(_t->_st->font, _t->_text, QFIXED_MAX, elideStart, 0, (*_elideSavedBlock)->flags(), (*_elideSavedBlock)->lnkIndex()); + mutableText->_blocks[blockIndex] = Block::Text(_t->_st->font, _t->_text, QFIXED_MAX, elideStart, 0, (*_elideSavedBlock)->flags(), (*_elideSavedBlock)->lnkIndex(), (*_elideSavedBlock)->spoilerIndex()); _blocksSize = blockIndex + 1; _endBlock = (blockIndex + 1 < _t->_blocks.size() ? _t->_blocks[blockIndex + 1].get() : nullptr); } @@ -1861,8 +2102,6 @@ private: } void prepareElidedLine(QString &lineText, int32 lineStart, int32 &lineLength, const AbstractBlock *&_endBlock, int repeat = 0) { - static const auto _Elide = QString::fromLatin1("..."); - _f = _t->_st->font; QStackTextEngine engine(lineText, _f->f); engine.option.setTextDirection(_parDirection); @@ -1899,10 +2138,11 @@ private: } if (_type == TextBlockTEmoji || _type == TextBlockTSkip || _type == TextBlockTNewline) { if (_wLeft < si.width) { - lineText = lineText.mid(0, currentBlock->from() - _localFrom) + _Elide; - lineLength = currentBlock->from() + _Elide.size() - _lineStart; + lineText = lineText.mid(0, currentBlock->from() - _localFrom) + kQEllipsis; + lineLength = currentBlock->from() + kQEllipsis.size() - _lineStart; _selection.to = qMin(_selection.to, currentBlock->from()); - setElideBidi(currentBlock->from(), _Elide.size()); + _indexOfElidedBlock = blockIndex + (nextBlock ? 1 : 0); + setElideBidi(currentBlock->from(), kQEllipsis.size()); elideSaveBlock(blockIndex - 1, _endBlock, currentBlock->from(), elideWidth); return; } @@ -1931,10 +2171,11 @@ private: } if (lineText.size() <= pos || repeat > 3) { - lineText += _Elide; - lineLength = _localFrom + pos + _Elide.size() - _lineStart; + lineText += kQEllipsis; + lineLength = _localFrom + pos + kQEllipsis.size() - _lineStart; _selection.to = qMin(_selection.to, uint16(_localFrom + pos)); - setElideBidi(_localFrom + pos, _Elide.size()); + _indexOfElidedBlock = blockIndex + (nextBlock ? 1 : 0); + setElideBidi(_localFrom + pos, kQEllipsis.size()); _blocksSize = blockIndex; _endBlock = nextBlock; } else { @@ -1954,10 +2195,11 @@ private: int32 elideStart = _localFrom + lineText.size(); _selection.to = qMin(_selection.to, uint16(elideStart)); - setElideBidi(elideStart, _Elide.size()); + _indexOfElidedBlock = blockIndex + (nextBlock ? 1 : 0); + setElideBidi(elideStart, kQEllipsis.size()); - lineText += _Elide; - lineLength += _Elide.size(); + lineText += kQEllipsis; + lineLength += kQEllipsis.size(); if (!repeat) { for (; blockIndex < _blocksSize && _t->_blocks[blockIndex].get() != _endBlock && _t->_blocks[blockIndex]->from() < elideStart; ++blockIndex) { @@ -2613,6 +2855,33 @@ private: void applyBlockProperties(const AbstractBlock *block) { eSetFont(block); if (_p) { + if (block->spoilerIndex()) { + const auto handler + = _t->_spoilers.at(block->spoilerIndex() - 1); + const auto inBack = (handler && handler->shown()); + _background.inFront = !inBack; + _background.color = inBack + ? &_textPalette->spoilerActiveBg + : &_textPalette->spoilerBg; + _background.startMs = handler ? handler->startMs() : 0; + _background.spoilerIndex = block->spoilerIndex(); + + const auto &cache = _background.inFront + ? _t->_spoilerCache + : _t->_spoilerShownCache; + if (cache.color != (*_background.color)->c) { + auto mutableText = const_cast(_t); + auto &mutableCache = _background.inFront + ? mutableText->_spoilerCache + : mutableText->_spoilerShownCache; + mutableCache.corners = Images::PrepareCorners( + ImageRoundRadius::Small, + *_background.color); + mutableCache.color = (*_background.color)->c; + } + } else { + _background = {}; + } if (block->lnkIndex()) { _currentPen = &_textPalette->linkFg->p; _currentPenSelected = &_textPalette->selectLinkFg->p; @@ -2637,6 +2906,12 @@ private: QPen _originalPenSelected; const QPen *_currentPen = nullptr; const QPen *_currentPenSelected = nullptr; + struct { + const style::color *color = nullptr; + bool inFront = false; + crl::time startMs = 0; + uint16 spoilerIndex = 0; + } _background; int _yFrom = 0; int _yTo = 0; int _yToElide = 0; @@ -2644,6 +2919,8 @@ private: bool _fullWidthSelection = true; const QChar *_str = nullptr; + int _indexOfElidedBlock = -1; // For spoilers. + // current paragraph data String::TextBlocks::const_iterator _parStartBlock; Qt::LayoutDirection _parDirection; @@ -2867,10 +3144,32 @@ void String::setLink(uint16 lnkIndex, const ClickHandlerPtr &lnk) { _links[lnkIndex - 1] = lnk; } +void String::setSpoiler( + uint16 lnkIndex, + const std::shared_ptr &lnk) { + if (!lnkIndex || lnkIndex > _spoilers.size()) { + return; + } + _spoilers[lnkIndex - 1] = lnk; +} + +void String::setSpoilerShown(uint16 lnkIndex, bool shown) { + if (!lnkIndex + || (lnkIndex > _spoilers.size()) + || !_spoilers[lnkIndex - 1]) { + return; + } + _spoilers[lnkIndex - 1]->setShown(shown); +} + bool String::hasLinks() const { return !_links.isEmpty(); } +int String::spoilersCount() const { + return _spoilers.size(); +} + bool String::hasSkipBlock() const { return _blocks.empty() ? false : _blocks.back()->type() == TextBlockTSkip; } @@ -2891,6 +3190,7 @@ bool String::updateSkipBlock(int width, int height) { _text.size() - 1, width, height, + 0, 0)); recountNaturalSize(false); return true; @@ -3153,9 +3453,14 @@ void String::enumerateText(TextSelection selection, AppendPartCallback appendPar int lnkIndex = 0; uint16 lnkFrom = 0; + + int spoilerIndex = 0; + uint16 spoilerFrom = 0; + int32 flags = 0; for (auto i = _blocks.cbegin(), e = _blocks.cend(); true; ++i) { int blockLnkIndex = (i == e) ? 0 : (*i)->lnkIndex(); + int blockSpoilerIndex = (i == e) ? 0 : (*i)->spoilerIndex(); uint16 blockFrom = (i == e) ? _text.size() : (*i)->from(); int32 blockFlags = (i == e) ? 0 : (*i)->flags(); @@ -3168,18 +3473,43 @@ void String::enumerateText(TextSelection selection, AppendPartCallback appendPar auto rangeTo = qMin(selection.to, blockFrom); if (rangeTo > rangeFrom) { // handle click handler const auto r = base::StringViewMid(_text, rangeFrom, rangeTo - rangeFrom); - if (lnkFrom != rangeFrom || blockFrom != rangeTo) { - // Ignore links that are partially copied. - clickHandlerFinishCallback(r, nullptr); - } else { - clickHandlerFinishCallback(r, _links.at(lnkIndex - 1)); - } + // Ignore links that are partially copied. + const auto handler = (lnkFrom != rangeFrom || blockFrom != rangeTo) + ? nullptr + : _links.at(lnkIndex - 1); + const auto type = handler + ? handler->getTextEntity().type + : EntityType::Invalid; + clickHandlerFinishCallback(r, handler, type); } } lnkIndex = blockLnkIndex; if (lnkIndex) { lnkFrom = blockFrom; - clickHandlerStartCallback(); + const auto handler = _links.at(lnkIndex - 1); + clickHandlerStartCallback(handler + ? handler->getTextEntity().type + : EntityType::Invalid); + } + } + if (blockSpoilerIndex != spoilerIndex) { + if (spoilerIndex) { + auto rangeFrom = qMax(selection.from, spoilerFrom); + auto rangeTo = qMin(selection.to, blockFrom); + if (rangeTo > rangeFrom) { // handle click handler + const auto r = base::StringViewMid(_text, rangeFrom, rangeTo - rangeFrom); + // Ignore links that are partially copied. + const auto handler = (spoilerFrom != rangeFrom || blockFrom != rangeTo) + ? nullptr + : _spoilers.at(spoilerIndex - 1); + const auto type = EntityType::Spoiler; + clickHandlerFinishCallback(r, handler, type); + } + } + spoilerIndex = blockSpoilerIndex; + if (spoilerIndex) { + spoilerFrom = blockFrom; + clickHandlerStartCallback(EntityType::Spoiler); } } @@ -3241,6 +3571,7 @@ TextForMimeData String::toText( result.rich.entities.insert(i, std::move(entity)); }; auto linkStart = 0; + auto spoilerStart = 0; auto markdownTrackers = composeEntities ? std::vector{ { TextBlockFItalic, EntityType::Italic }, @@ -3249,7 +3580,7 @@ TextForMimeData String::toText( { TextBlockFUnderline, EntityType::Underline }, { TextBlockFStrikeOut, EntityType::StrikeOut }, { TextBlockFCode, EntityType::Code }, // #TODO entities - { TextBlockFPre, EntityType::Pre } + { TextBlockFPre, EntityType::Pre }, } : std::vector(); const auto flagsChangeCallback = [&](int32 oldFlags, int32 newFlags) { if (!composeEntities) { @@ -3267,12 +3598,26 @@ TextForMimeData String::toText( } } }; - const auto clickHandlerStartCallback = [&] { - linkStart = result.rich.text.size(); + const auto clickHandlerStartCallback = [&](EntityType type) { + if (type == EntityType::Spoiler) { + spoilerStart = result.rich.text.size(); + } else { + linkStart = result.rich.text.size(); + } }; const auto clickHandlerFinishCallback = [&]( QStringView inText, - const ClickHandlerPtr &handler) { + const ClickHandlerPtr &handler, + EntityType type) { + if (type == EntityType::Spoiler) { + insertEntity({ + type, + spoilerStart, + int(result.rich.text.size() - spoilerStart), + QString(), + }); + return; + } if (!handler || (!composeExpanded && !composeEntities)) { return; } @@ -3344,7 +3689,8 @@ IsolatedEmoji String::toIsolatedEmoji() const { auto result = IsolatedEmoji(); const auto skip = (_blocks.empty() || _blocks.back()->type() != TextBlockTSkip) ? 0 : 1; - if (_blocks.size() > kIsolatedEmojiLimit + skip) { + if ((_blocks.size() > kIsolatedEmojiLimit + skip) + || !_spoilers.empty()) { return IsolatedEmoji(); } auto index = 0; @@ -3369,10 +3715,19 @@ void String::clear() { void String::clearFields() { _blocks.clear(); _links.clear(); + _spoilers.clear(); _maxWidth = _minHeight = 0; _startDir = Qt::LayoutDirectionAuto; } +ClickHandlerPtr String::spoilerLink(uint16 spoilerIndex) const { + if (spoilerIndex) { + const auto &handler = _spoilers.at(spoilerIndex - 1); + return (handler && !handler->shown()) ? handler : nullptr; + } + return nullptr; +} + bool IsWordSeparator(QChar ch) { switch (ch.unicode()) { case QChar::Space: diff --git a/ui/text/text.h b/ui/text/text.h index 75bc9c0..02612c6 100644 --- a/ui/text/text.h +++ b/ui/text/text.h @@ -15,6 +15,12 @@ #include #include +class SpoilerClickHandler; + +namespace Ui { +static const auto kQEllipsis = QStringLiteral("..."); +} // namespace Ui + static const QChar TextCommand(0x0010); enum TextCommands { TextCommandBold = 0x01, @@ -30,6 +36,8 @@ enum TextCommands { TextCommandLinkIndex = 0x0B, // 0 - NoLink TextCommandLinkText = 0x0C, TextCommandSkipBlock = 0x0D, + TextCommandSpoiler = 0x0E, + TextCommandNoSpoiler = 0x0F, TextCommandLangTag = 0x20, }; @@ -131,6 +139,11 @@ public: void setLink(uint16 lnkIndex, const ClickHandlerPtr &lnk); bool hasLinks() const; + void setSpoiler( + uint16 lnkIndex, + const std::shared_ptr &lnk); + void setSpoilerShown(uint16 lnkIndex, bool shown); + int spoilersCount() const; bool hasSkipBlock() const; bool updateSkipBlock(int width, int height); @@ -205,6 +218,8 @@ private: // it is also called from move constructor / assignment operator void clearFields(); + ClickHandlerPtr spoilerLink(uint16 spoilerIndex) const; + TextForMimeData toText( TextSelection selection, bool composeExpanded, @@ -220,8 +235,15 @@ private: TextBlocks _blocks; TextLinks _links; + QVector> _spoilers; + Qt::LayoutDirection _startDir = Qt::LayoutDirectionAuto; + struct { + std::array corners; + QColor color; + } _spoilerCache, _spoilerShownCache; + friend class Parser; friend class Renderer; @@ -264,4 +286,8 @@ QString textcmdLink(ushort lnkIndex, const QString &text); QString textcmdLink(const QString &url, const QString &text); QString textcmdStartSemibold(); QString textcmdStopSemibold(); + +QString textcmdStartSpoiler(); +QString textcmdStopSpoiler(); + const QChar *textSkipCommand(const QChar *from, const QChar *end, bool canLink = true); diff --git a/ui/text/text_block.cpp b/ui/text/text_block.cpp index bc34603..1e344e0 100644 --- a/ui/text/text_block.cpp +++ b/ui/text/text_block.cpp @@ -340,6 +340,63 @@ bool BlockParser::isLineBreak( return lineBreak; } +AbstractBlock::AbstractBlock( + const style::font &font, + const QString &str, + uint16 from, + uint16 length, + uchar flags, + uint16 lnkIndex, + uint16 spoilerIndex) +: _from(from) +, _flags((flags & 0xFF) | ((lnkIndex & 0xFFFF) << 12)) +, _spoilerIndex(spoilerIndex) { +} + +uint16 AbstractBlock::from() const { + return _from; +} + +int AbstractBlock::width() const { + return _width.toInt(); +} + +int AbstractBlock::rpadding() const { + return _rpadding.toInt(); +} + +QFixed AbstractBlock::f_width() const { + return _width; +} + +QFixed AbstractBlock::f_rpadding() const { + return _rpadding; +} + +uint16 AbstractBlock::lnkIndex() const { + return (_flags >> 12) & 0xFFFF; +} + +void AbstractBlock::setLnkIndex(uint16 lnkIndex) { + _flags = (_flags & ~(0xFFFF << 12)) | (lnkIndex << 12); +} + +uint16 AbstractBlock::spoilerIndex() const { + return _spoilerIndex; +} + +void AbstractBlock::setSpoilerIndex(uint16 spoilerIndex) { + _spoilerIndex = spoilerIndex; +} + +TextBlockType AbstractBlock::type() const { + return TextBlockType((_flags >> 8) & 0x0F); +} + +int32 AbstractBlock::flags() const { + return (_flags & 0xFFF); +} + QFixed AbstractBlock::f_rbearing() const { return (type() == TextBlockTText) ? static_cast(this)->real_f_rbearing() @@ -353,8 +410,9 @@ TextBlock::TextBlock( uint16 from, uint16 length, uchar flags, - uint16 lnkIndex) -: AbstractBlock(font, str, from, length, flags, lnkIndex) { + uint16 lnkIndex, + uint16 spoilerIndex) +: AbstractBlock(font, str, from, length, flags, lnkIndex, spoilerIndex) { _flags |= ((TextBlockTText & 0x0F) << 8); if (length) { style::font blockFont = font; @@ -395,8 +453,9 @@ EmojiBlock::EmojiBlock( uint16 length, uchar flags, uint16 lnkIndex, + uint16 spoilerIndex, EmojiPtr emoji) -: AbstractBlock(font, str, from, length, flags, lnkIndex) +: AbstractBlock(font, str, from, length, flags, lnkIndex, spoilerIndex) , _emoji(emoji) { _flags |= ((TextBlockTEmoji & 0x0F) << 8); _width = int(st::emojiSize + 2 * st::emojiPadding); diff --git a/ui/text/text_block.h b/ui/text/text_block.h index 5f99108..e1a6534 100644 --- a/ui/text/text_block.h +++ b/ui/text/text_block.h @@ -34,38 +34,23 @@ enum TextBlockFlags { class AbstractBlock { public: - uint16 from() const { - return _from; - } - int width() const { - return _width.toInt(); - } - int rpadding() const { - return _rpadding.toInt(); - } - QFixed f_width() const { - return _width; - } - QFixed f_rpadding() const { - return _rpadding; - } + uint16 from() const; + int width() const; + int rpadding() const; + QFixed f_width() const; + QFixed f_rpadding() const; // Should be virtual, but optimized throught type() call. QFixed f_rbearing() const; - uint16 lnkIndex() const { - return (_flags >> 12) & 0xFFFF; - } - void setLnkIndex(uint16 lnkIndex) { - _flags = (_flags & ~(0xFFFF << 12)) | (lnkIndex << 12); - } + uint16 lnkIndex() const; + void setLnkIndex(uint16 lnkIndex); - TextBlockType type() const { - return TextBlockType((_flags >> 8) & 0x0F); - } - int32 flags() const { - return (_flags & 0xFF); - } + uint16 spoilerIndex() const; + void setSpoilerIndex(uint16 spoilerIndex); + + TextBlockType type() const; + int32 flags() const; protected: AbstractBlock( @@ -74,15 +59,15 @@ protected: uint16 from, uint16 length, uchar flags, - uint16 lnkIndex) - : _from(from) - , _flags((flags & 0xFF) | ((lnkIndex & 0xFFFF) << 12)) { - } + uint16 lnkIndex, + uint16 spoilerIndex); uint16 _from = 0; uint32 _flags = 0; // 4 bits empty, 16 bits lnkIndex, 4 bits type, 8 bits flags + uint16 _spoilerIndex = 0; + QFixed _width = 0; // Right padding: spaces after the last content of the block (like a word). @@ -102,8 +87,9 @@ public: uint16 from, uint16 length, uchar flags, - uint16 lnkIndex) - : AbstractBlock(font, str, from, length, flags, lnkIndex) { + uint16 lnkIndex, + uint16 spoilerIndex) + : AbstractBlock(font, str, from, length, flags, lnkIndex, spoilerIndex) { _flags |= ((TextBlockTNewline & 0x0F) << 8); } @@ -163,7 +149,8 @@ public: uint16 from, uint16 length, uchar flags, - uint16 lnkIndex); + uint16 lnkIndex, + uint16 spoilerIndex); private: QFixed real_f_rbearing() const { @@ -189,6 +176,7 @@ public: uint16 length, uchar flags, uint16 lnkIndex, + uint16 spoilerIndex, EmojiPtr emoji); private: @@ -208,8 +196,9 @@ public: uint16 from, int32 w, int32 h, - uint16 lnkIndex) - : AbstractBlock(font, str, from, 1, 0, lnkIndex) + uint16 lnkIndex, + uint16 spoilerIndex) + : AbstractBlock(font, str, from, 1, 0, lnkIndex, spoilerIndex) , _height(h) { _flags |= ((TextBlockTSkip & 0x0F) << 8); _width = w; @@ -325,8 +314,9 @@ public: uint16 from, uint16 length, uchar flags, - uint16 lnkIndex) { - return New(font, str, from, length, flags, lnkIndex); + uint16 lnkIndex, + uint16 spoilerIndex) { + return New(font, str, from, length, flags, lnkIndex, spoilerIndex); } [[nodiscard]] static Block Text( @@ -336,7 +326,8 @@ public: uint16 from, uint16 length, uchar flags, - uint16 lnkIndex) { + uint16 lnkIndex, + uint16 spoilerIndex) { return New( font, str, @@ -344,7 +335,8 @@ public: from, length, flags, - lnkIndex); + lnkIndex, + spoilerIndex); } [[nodiscard]] static Block Emoji( @@ -354,6 +346,7 @@ public: uint16 length, uchar flags, uint16 lnkIndex, + uint16 spoilerIndex, EmojiPtr emoji) { return New( font, @@ -362,6 +355,7 @@ public: length, flags, lnkIndex, + spoilerIndex, emoji); } @@ -371,8 +365,9 @@ public: uint16 from, int32 w, int32 h, - uint16 lnkIndex) { - return New(font, str, from, w, h, lnkIndex); + uint16 lnkIndex, + uint16 spoilerIndex) { + return New(font, str, from, w, h, lnkIndex, spoilerIndex); } template diff --git a/ui/text/text_entity.cpp b/ui/text/text_entity.cpp index 614d967..5c59c1a 100644 --- a/ui/text/text_entity.cpp +++ b/ui/text/text_entity.cpp @@ -22,6 +22,8 @@ namespace TextUtilities { namespace { +constexpr auto kTagSeparator = '$'; + using namespace Ui::Text; QString ExpressionMailNameAtEnd() { @@ -68,6 +70,10 @@ QString SeparatorsMono() { return Separators(QString::fromUtf8("*~/")); } +QString SeparatorsSpoiler() { + return Separators(QString::fromUtf8("|*~/")); +} + QString ExpressionHashtag() { return QString::fromUtf8("(^|[") + ExpressionSeparators(QString::fromUtf8("`\\*/")) + QString::fromUtf8("])#[\\w]{2,64}([\\W]|$)"); } @@ -1269,6 +1275,14 @@ QString MarkdownPreBadAfter() { return QString::fromLatin1("`"); } +QString MarkdownSpoilerGoodBefore() { + return SeparatorsSpoiler(); +} + +QString MarkdownSpoilerBadAfter() { + return QString::fromLatin1("|"); +} + bool IsValidProtocol(const QString &protocol) { static const auto list = CreateValidProtocols(); return list.contains(base::crc32(protocol.constData(), protocol.size() * sizeof(QChar))); @@ -1279,9 +1293,17 @@ bool IsValidTopDomain(const QString &protocol) { return list.contains(base::crc32(protocol.constData(), protocol.size() * sizeof(QChar))); } -QString Clean(const QString &text) { +QString Clean(const QString &text, bool keepSpoilers) { auto result = text; for (auto s = text.unicode(), ch = s, e = text.unicode() + text.size(); ch != e; ++ch) { + if (keepSpoilers && (*ch == TextCommand)) { + if ((*(ch + 1) == TextCommandSpoiler) + || (*(ch - 1) == TextCommandSpoiler) + || (*(ch + 1) == TextCommandNoSpoiler) + || (*(ch - 1) == TextCommandNoSpoiler)) { + continue; + } + } if (*ch == TextCommand) { result[int(ch - s)] = QChar::Space; } @@ -2041,17 +2063,21 @@ QString JoinTag(const QList &list) { result.append(list.front()); for (auto i = 1, count = int(list.size()); i != count; ++i) { if (!IsSeparateTag(list[i])) { - result.append('|').append(list[i]); + result.append(kTagSeparator).append(list[i]); } } return result; } +QList SplitTags(const QString &tag) { + return QStringView(tag).split(kTagSeparator); +} + QString TagWithRemoved(const QString &tag, const QString &removed) { if (tag == removed) { return QString(); } - auto list = QStringView(tag).split('|'); + auto list = SplitTags(tag); list.erase(ranges::remove(list, QStringView(removed)), list.end()); return JoinTag(list); } @@ -2060,7 +2086,7 @@ QString TagWithAdded(const QString &tag, const QString &added) { if (tag.isEmpty() || tag == added) { return added; } - auto list = QStringView(tag).split('|'); + auto list = SplitTags(tag); const auto ref = QStringView(added); if (list.contains(ref)) { return tag; @@ -2081,6 +2107,7 @@ EntitiesInText ConvertTextTagsToEntities(const TextWithTags::Tags &tags) { EntityType::Italic, EntityType::Underline, EntityType::StrikeOut, + EntityType::Spoiler, EntityType::Code, EntityType::Pre, }; @@ -2173,7 +2200,7 @@ EntitiesInText ConvertTextTagsToEntities(const TextWithTags::Tags &tags) { }; const auto stateForTag = [&](const QString &tag) { auto result = State(); - const auto list = QStringView(tag).split('|'); + const auto list = SplitTags(tag); for (const auto &single : list) { if (single == Ui::InputField::kTagBold) { result.set(EntityType::Bold); @@ -2187,6 +2214,8 @@ EntitiesInText ConvertTextTagsToEntities(const TextWithTags::Tags &tags) { result.set(EntityType::Code); } else if (single == Ui::InputField::kTagPre) { result.set(EntityType::Pre); + } else if (single == Ui::InputField::kTagSpoiler) { + result.set(EntityType::Spoiler); } else { result.link = single.toString(); } @@ -2275,6 +2304,7 @@ TextWithTags::Tags ConvertEntitiesToTextTags( break; case EntityType::Code: push(Ui::InputField::kTagCode); break; // #TODO entities case EntityType::Pre: push(Ui::InputField::kTagPre); break; + case EntityType::Spoiler: push(Ui::InputField::kTagSpoiler); break; } } if (!toRemove.empty()) { @@ -2302,6 +2332,36 @@ void SetClipboardText( } } +QString TextWithSpoilerCommands(const TextWithEntities &textWithEntities) { + auto text = textWithEntities.text; + auto offset = 0; + const auto start = textcmdStartSpoiler(); + const auto stop = textcmdStopSpoiler(); + for (const auto &e : textWithEntities.entities) { + if (e.type() == EntityType::Spoiler) { + text.insert(e.offset() + offset, start); + offset += start.size(); + text.insert(e.offset() + e.length() + offset, stop); + offset += stop.size(); + } + } + return text; +} + +QString CutTextWithCommands( + QString text, + int length, + const QString &start, + const QString &stop) { + text = text.mid(0, length); + const auto lastStart = text.lastIndexOf(start); + const auto lastStop = text.lastIndexOf(stop); + const auto additional = ((lastStart == -1) || (lastStart < lastStop)) + ? QString() + : stop; + return text + additional + Ui::kQEllipsis; +} + } // namespace TextUtilities EntityInText::EntityInText( diff --git a/ui/text/text_entity.h b/ui/text/text_entity.h index d140fbc..708500b 100644 --- a/ui/text/text_entity.h +++ b/ui/text/text_entity.h @@ -33,6 +33,7 @@ enum class EntityType : uchar { StrikeOut, Code, // inline Pre, // block + Spoiler, }; enum class EntityLinkShown : uchar { @@ -293,9 +294,11 @@ QString MarkdownCodeGoodBefore(); QString MarkdownCodeBadAfter(); QString MarkdownPreGoodBefore(); QString MarkdownPreBadAfter(); +QString MarkdownSpoilerGoodBefore(); +QString MarkdownSpoilerBadAfter(); // Text preprocess. -QString Clean(const QString &text); +QString Clean(const QString &text, bool keepSpoilers = false); QString EscapeForRichParsing(const QString &text); QString SingleLine(const QString &text); TextWithEntities SingleLine(const TextWithEntities &text); @@ -366,6 +369,7 @@ inline const auto kMentionTagStart = qstr("mention://user."); [[nodiscard]] bool IsMentionLink(QStringView link); [[nodiscard]] bool IsSeparateTag(QStringView tag); [[nodiscard]] QString JoinTag(const QList &list); +[[nodiscard]] QList SplitTags(const QString &tag); [[nodiscard]] QString TagWithRemoved( const QString &tag, const QString &removed); @@ -380,4 +384,12 @@ void SetClipboardText( const TextForMimeData &text, QClipboard::Mode mode = QClipboard::Clipboard); +[[nodiscard]] QString TextWithSpoilerCommands( + const TextWithEntities &textWithEntities); +[[nodiscard]] QString CutTextWithCommands( + QString text, + int length, + const QString &start, + const QString &stop); + } // namespace TextUtilities diff --git a/ui/widgets/checkbox.cpp b/ui/widgets/checkbox.cpp index e75482b..97afa5b 100644 --- a/ui/widgets/checkbox.cpp +++ b/ui/widgets/checkbox.cpp @@ -380,7 +380,22 @@ Checkbox::Checkbox( const style::Check &checkSt) : Checkbox( parent, - text, + rpl::single(text) | rpl::map(TextWithEntities::Simple), + st, + std::make_unique( + checkSt, + checked)) { +} + +Checkbox::Checkbox( + QWidget *parent, + const TextWithEntities &text, + bool checked, + const style::Checkbox &st, + const style::Check &checkSt) +: Checkbox( + parent, + rpl::single(text), st, std::make_unique( checkSt, @@ -395,7 +410,7 @@ Checkbox::Checkbox( const style::Toggle &toggleSt) : Checkbox( parent, - rpl::single(text), + rpl::single(text) | rpl::map(TextWithEntities::Simple), st, std::make_unique( toggleSt, @@ -410,7 +425,7 @@ Checkbox::Checkbox( const style::Check &checkSt) : Checkbox( parent, - std::move(text), + std::move(text) | rpl::map(TextWithEntities::Simple), st, std::make_unique( checkSt, @@ -425,7 +440,7 @@ Checkbox::Checkbox( const style::Toggle &toggleSt) : Checkbox( parent, - std::move(text), + std::move(text) | rpl::map(TextWithEntities::Simple), st, std::make_unique( toggleSt, @@ -439,14 +454,14 @@ Checkbox::Checkbox( std::unique_ptr check) : Checkbox( parent, - rpl::single(text), + rpl::single(text) | rpl::map(TextWithEntities::Simple), st, std::move(check)) { } Checkbox::Checkbox( QWidget *parent, - rpl::producer &&text, + rpl::producer &&text, const style::Checkbox &st, std::unique_ptr check) : RippleButton(parent, st.ripple) @@ -462,8 +477,17 @@ Checkbox::Checkbox( setCursor(style::cur_pointer); std::move( text - ) | rpl::start_with_next([=](QString &&value) { - setText(std::move(value)); + ) | rpl::start_with_next([=](TextWithEntities &&value) { + if (value.entities.empty()) { + setText(base::take(value.text)); + } else { + _text.setMarkedText( + _st.style, + std::move(value), + _checkboxRichOptions); + resizeToText(); + update(); + } }, lifetime()); } diff --git a/ui/widgets/checkbox.h b/ui/widgets/checkbox.h index 7c0d7de..eeb5bf5 100644 --- a/ui/widgets/checkbox.h +++ b/ui/widgets/checkbox.h @@ -140,6 +140,12 @@ public: bool checked = false, const style::Checkbox &st = st::defaultCheckbox, const style::Check &checkSt = st::defaultCheck); + Checkbox( + QWidget *parent, + const TextWithEntities &text, + bool checked = false, + const style::Checkbox &st = st::defaultCheckbox, + const style::Check &checkSt = st::defaultCheck); Checkbox( QWidget *parent, const QString &text, @@ -165,7 +171,7 @@ public: std::unique_ptr check); Checkbox( QWidget *parent, - rpl::producer &&text, + rpl::producer &&text, const style::Checkbox &st, std::unique_ptr check); diff --git a/ui/widgets/input_fields.cpp b/ui/widgets/input_fields.cpp index 6fb7b01..e7379b8 100644 --- a/ui/widgets/input_fields.cpp +++ b/ui/widgets/input_fields.cpp @@ -46,6 +46,7 @@ const auto &kTagUnderline = InputField::kTagUnderline; const auto &kTagStrikeOut = InputField::kTagStrikeOut; const auto &kTagCode = InputField::kTagCode; const auto &kTagPre = InputField::kTagPre; +const auto &kTagSpoiler = InputField::kTagSpoiler; const auto kTagCheckLinkMeta = QString("^:/:/:^"); const auto kNewlineChars = QString("\r\n") + QChar(0xfdd0) // QTextBeginningOfFrame @@ -129,7 +130,7 @@ bool IsNewline(QChar ch) { return QString(); } auto found = false; - for (const auto &single : QStringView(existing.id).split('|')) { + for (const auto &single : TextUtilities::SplitTags(existing.id)) { const auto normalized = (single == QStringView(kTagPre)) ? QStringView(kTagCode) : single; @@ -234,6 +235,7 @@ constexpr auto kTagItalicIndex = 1; constexpr auto kTagStrikeOutIndex = 2; constexpr auto kTagCodeIndex = 3; constexpr auto kTagPreIndex = 4; +constexpr auto kTagSpoilerIndex = 5; constexpr auto kInvalidPosition = std::numeric_limits::max() / 2; class TagSearchItem { @@ -377,6 +379,13 @@ const std::vector &TagStartExpressions() { TextUtilities::MarkdownPreBadAfter(), TextUtilities::MarkdownPreGoodBefore() }, + { + kTagSpoiler, + TextUtilities::MarkdownSpoilerGoodBefore(), + TextUtilities::MarkdownSpoilerBadAfter(), + TextUtilities::MarkdownSpoilerBadAfter(), + TextUtilities::MarkdownSpoilerGoodBefore() + }, }; return cached; } @@ -389,6 +398,7 @@ const std::map &TagIndices() { { kTagStrikeOut, kTagStrikeOutIndex }, { kTagCode, kTagCodeIndex }, { kTagPre, kTagPreIndex }, + { kTagSpoiler, kTagSpoilerIndex }, }; return cached; } @@ -706,6 +716,7 @@ QTextCharFormat PrepareTagFormat( auto result = QTextCharFormat(); auto font = st.font; auto color = std::optional(); + auto bg = std::optional(); const auto applyOne = [&](QStringView tag) { if (IsValidMarkdownLink(tag)) { color = st::defaultTextPalette.linkFg; @@ -720,14 +731,19 @@ QTextCharFormat PrepareTagFormat( } else if (tag == kTagCode || tag == kTagPre) { color = st::defaultTextPalette.monoFg; font = font->monospace(); + } else if (tag == kTagSpoiler) { + bg = st::defaultTextPalette.spoilerActiveBg; } }; - for (const auto &tag : QStringView(tag).split('|')) { + for (const auto &tag : TextUtilities::SplitTags(tag)) { applyOne(tag); } result.setFont(font); result.setForeground(color.value_or(st.textFg)); result.setProperty(kTagProperty, tag); + if (bg) { + result.setBackground(*bg); + } return result; } @@ -850,6 +866,7 @@ const QString InputField::kTagUnderline = QStringLiteral("^^"); const QString InputField::kTagStrikeOut = QStringLiteral("~~"); const QString InputField::kTagCode = QStringLiteral("`"); const QString InputField::kTagPre = QStringLiteral("```"); +const QString InputField::kTagSpoiler = QStringLiteral("||"); class InputField::Inner final : public QTextEdit { public: @@ -1185,8 +1202,9 @@ void FlatInput::refreshPlaceholder(const QString &text) { } void FlatInput::contextMenuEvent(QContextMenuEvent *e) { - if (auto menu = createStandardContextMenu()) { - (new PopupMenu(this, menu))->popup(e->globalPos()); + if (const auto menu = createStandardContextMenu()) { + _contextMenu = base::make_unique_q(this, menu); + _contextMenu->popup(e->globalPos()); } } @@ -2869,6 +2887,8 @@ bool InputField::handleMarkdownKey(QKeyEvent *e) { toggleSelectionMarkdown(kTagStrikeOut); } else if (matches(kMonospaceSequence)) { toggleSelectionMarkdown(kTagCode); + } else if (matches(kSpoilerSequence)) { + toggleSelectionMarkdown(kTagSpoiler); } else if (matches(kClearFormatSequence)) { clearSelectionMarkdown(); } else if (matches(kEditLinkSequence) && _editLinkCallback) { @@ -2953,8 +2973,8 @@ auto InputField::selectionEditLinkData(EditLinkSelection selection) const }; const auto stateTagHasLink = [&](const State &state) { const auto tag = stateTag(state); - return (tag == link) || QStringView(tag).split('|').contains( - QStringView(link)); + return (tag == link) + || TextUtilities::SplitTags(tag).contains(QStringView(link)); }; const auto stateStart = [&](const State &state) { return state.i.fragment().position(); @@ -3162,7 +3182,7 @@ void InputField::commitInstantReplacement( const auto currentTag = cursor.charFormat().property( kTagProperty ).toString(); - const auto currentTags = QStringView(currentTag).split('|'); + const auto currentTags = TextUtilities::SplitTags(currentTag); if (currentTags.contains(QStringView(kTagPre)) || currentTags.contains(QStringView(kTagCode))) { return; @@ -3628,6 +3648,7 @@ void InputField::addMarkdownActions( addtag(integration.phraseFormattingUnderline(), QKeySequence::Underline, kTagUnderline); addtag(integration.phraseFormattingStrikeOut(), kStrikeOutSequence, kTagStrikeOut); addtag(integration.phraseFormattingMonospace(), kMonospaceSequence, kTagCode); + addtag(integration.phraseFormattingSpoiler(), kSpoilerSequence, kTagSpoiler); if (_editLinkCallback) { submenu->addSeparator(); @@ -4075,7 +4096,8 @@ void MaskedInputField::setPlaceholder(rpl::producer placeholder) { void MaskedInputField::contextMenuEvent(QContextMenuEvent *e) { if (const auto menu = createStandardContextMenu()) { - (new PopupMenu(this, menu))->popup(e->globalPos()); + _contextMenu = base::make_unique_q(this, menu); + _contextMenu->popup(e->globalPos()); } } diff --git a/ui/widgets/input_fields.h b/ui/widgets/input_fields.h index ffef187..d35a7b2 100644 --- a/ui/widgets/input_fields.h +++ b/ui/widgets/input_fields.h @@ -28,6 +28,7 @@ const auto kClearFormatSequence = QKeySequence("ctrl+shift+n"); const auto kStrikeOutSequence = QKeySequence("ctrl+shift+x"); const auto kMonospaceSequence = QKeySequence("ctrl+shift+m"); const auto kEditLinkSequence = QKeySequence("ctrl+k"); +const auto kSpoilerSequence = QKeySequence("ctrl+shift+p"); class PopupMenu; @@ -140,6 +141,9 @@ private: QTimer _touchTimer; bool _touchPress, _touchRightButton, _touchMove; QPoint _touchStart; + + base::unique_qptr _contextMenu; + }; class InputField : public RpWidget { @@ -171,6 +175,7 @@ public: static const QString kTagStrikeOut; static const QString kTagCode; static const QString kTagPre; + static const QString kTagSpoiler; InputField( QWidget *parent, @@ -700,6 +705,9 @@ private: bool _touchRightButton = false; bool _touchMove = false; QPoint _touchStart; + + base::unique_qptr _contextMenu; + }; class PasswordInput : public MaskedInputField { diff --git a/ui/widgets/menu/menu.cpp b/ui/widgets/menu/menu.cpp index 8b7a90e..23c6bd0 100644 --- a/ui/widgets/menu/menu.cpp +++ b/ui/widgets/menu/menu.cpp @@ -68,10 +68,12 @@ not_null Menu::addAction( not_null Menu::addAction( const QString &text, - std::unique_ptr submenu) { + std::unique_ptr submenu, + const style::icon *icon, + const style::icon *iconOver) { const auto action = new QAction(text, this); action->setMenu(submenu.release()); - return addAction(action, nullptr, nullptr); + return addAction(action, icon, iconOver); } not_null Menu::addAction( @@ -365,8 +367,19 @@ void Menu::mouseReleaseEvent(QMouseEvent *e) { void Menu::handleMousePress(QPoint globalPosition) { handleMouseMove(globalPosition); - if (_mousePressDelegate) { - _mousePressDelegate(globalPosition); + const auto margins = style::margins(0, _st.skip, 0, _st.skip); + const auto inner = rect().marginsRemoved(margins); + const auto localPosition = mapFromGlobal(globalPosition); + const auto pressed = (inner.contains(localPosition) + && _lastSelectedByMouse) + ? findSelectedAction() + : nullptr; + if (pressed) { + pressed->setClicked(); + } else { + if (_mousePressDelegate) { + _mousePressDelegate(globalPosition); + } } } diff --git a/ui/widgets/menu/menu.h b/ui/widgets/menu/menu.h index 0af9898..3aa0b05 100644 --- a/ui/widgets/menu/menu.h +++ b/ui/widgets/menu/menu.h @@ -40,7 +40,9 @@ public: const style::icon *iconOver = nullptr); not_null addAction( const QString &text, - std::unique_ptr submenu); + std::unique_ptr submenu, + const style::icon *icon = nullptr, + const style::icon *iconOver = nullptr); not_null addSeparator(); void clearActions(); void finishAnimating(); diff --git a/ui/widgets/popup_menu.cpp b/ui/widgets/popup_menu.cpp index 37faaaa..308fed7 100644 --- a/ui/widgets/popup_menu.cpp +++ b/ui/widgets/popup_menu.cpp @@ -357,14 +357,24 @@ not_null PopupMenu::addAction( return _menu->addAction(std::move(widget)); } -not_null PopupMenu::addAction(const QString &text, Fn callback, const style::icon *icon, const style::icon *iconOver) { +not_null PopupMenu::addAction( + const QString &text, + Fn callback, + const style::icon *icon, + const style::icon *iconOver) { return _menu->addAction(text, std::move(callback), icon, iconOver); } not_null PopupMenu::addAction( const QString &text, - std::unique_ptr submenu) { - const auto action = _menu->addAction(text, std::make_unique()); + std::unique_ptr submenu, + const style::icon *icon, + const style::icon *iconOver) { + const auto action = _menu->addAction( + text, + std::make_unique(), + icon, + iconOver); const auto saved = _submenus.emplace( action, base::unique_qptr(submenu.release()) diff --git a/ui/widgets/popup_menu.h b/ui/widgets/popup_menu.h index 510015e..26937dd 100644 --- a/ui/widgets/popup_menu.h +++ b/ui/widgets/popup_menu.h @@ -29,8 +29,16 @@ public: } not_null addAction(base::unique_qptr widget); - not_null addAction(const QString &text, Fn callback, const style::icon *icon = nullptr, const style::icon *iconOver = nullptr); - not_null addAction(const QString &text, std::unique_ptr submenu); + not_null addAction( + const QString &text, + Fn callback, + const style::icon *icon = nullptr, + const style::icon *iconOver = nullptr); + not_null addAction( + const QString &text, + std::unique_ptr submenu, + const style::icon *icon = nullptr, + const style::icon *iconOver = nullptr); not_null addSeparator(); void clearActions(); diff --git a/ui/widgets/widgets.style b/ui/widgets/widgets.style index 2254d09..af1f47e 100644 --- a/ui/widgets/widgets.style +++ b/ui/widgets/widgets.style @@ -857,7 +857,7 @@ defaultMenu: Menu { arrow: defaultMenuArrow; - widthMin: 180px; + widthMin: 140px; widthMax: 300px; ripple: defaultRippleAnimation;