diff --git a/Telegram/SourceFiles/data/data_document_resolver.cpp b/Telegram/SourceFiles/data/data_document_resolver.cpp index 774c12a6ea..20fc12fc94 100644 --- a/Telegram/SourceFiles/data/data_document_resolver.cpp +++ b/Telegram/SourceFiles/data/data_document_resolver.cpp @@ -11,6 +11,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/abstract_box.h" // Ui::show(). #include "chat_helpers/ttl_media_layer_widget.h" #include "core/application.h" +#include "core/click_handler_types.h" #include "core/core_settings.h" #include "core/mime_type.h" #include "data/data_document.h" @@ -251,7 +252,17 @@ void ResolveDocument( if (!openImageInApp()) { const auto path = document->filepath(true); if (!path.isEmpty()) { - if (!Core::App().iv().showMarkdown(path)) { + auto context = QVariant(); + if (item) { + auto clickHandlerContext = ClickHandlerContext(); + clickHandlerContext.itemId = item->fullId(); + if (controller) { + clickHandlerContext.sessionWindow = controller; + clickHandlerContext.show = controller->uiShow(); + } + context = QVariant::fromValue(clickHandlerContext); + } + if (!Core::App().iv().showMarkdown(path, context)) { LaunchWithWarning(path, item); } } else if (document->status == FileReady diff --git a/Telegram/SourceFiles/iv/iv.style b/Telegram/SourceFiles/iv/iv.style index 5bb94ed8ba..fbbbecca2b 100644 --- a/Telegram/SourceFiles/iv/iv.style +++ b/Telegram/SourceFiles/iv/iv.style @@ -175,7 +175,7 @@ defaultMarkdownBodyStyle: TextStyle(historyTextStyle) { lineHeight: 24px; } defaultMarkdownCodeStyle: TextStyle(defaultMarkdownBodyStyle) { - font: font(15px); + font: font(14px); lineHeight: 22px; } defaultMarkdownDisplayMathFallbackStyle: TextStyle(defaultMarkdownCodeStyle) { @@ -200,7 +200,7 @@ defaultMarkdownList: MarkdownList { continuationIndent: 22px; markerWidth: 22px; markerSkip: 8px; - bulletRadius: 3px; + bulletRadius: 2px; bulletLeftShift: 0px; bulletFg: windowFg; taskCheck: defaultCheck; diff --git a/Telegram/SourceFiles/iv/iv_controller.cpp b/Telegram/SourceFiles/iv/iv_controller.cpp index 87ab701e8c..8bce01f863 100644 --- a/Telegram/SourceFiles/iv/iv_controller.cpp +++ b/Telegram/SourceFiles/iv/iv_controller.cpp @@ -46,6 +46,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include #include +#include #include #include #include @@ -689,6 +690,10 @@ void Controller::createWebview(const Webview::StorageId &storageId) { return base::EventFilterResult::Continue; } const auto event = static_cast(e.get()); + if (event->matches(QKeySequence::Close)) { + close(); + return base::EventFilterResult::Cancel; + } if (event->modifiers() & Qt::ControlModifier) { if (event->key() == Qt::Key_Plus || event->key() == Qt::Key_Equal) { diff --git a/Telegram/SourceFiles/iv/iv_instance.cpp b/Telegram/SourceFiles/iv/iv_instance.cpp index c076012d40..4a23566741 100644 --- a/Telegram/SourceFiles/iv/iv_instance.cpp +++ b/Telegram/SourceFiles/iv/iv_instance.cpp @@ -26,6 +26,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_thread.h" #include "data/data_web_page.h" #include "data/data_user.h" +#include "history/history.h" +#include "history/history_item.h" #include "history/history_item_helpers.h" #include "info/profile/info_profile_values.h" #include "iv/markdown/iv_markdown_controller.h" @@ -54,9 +56,13 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "window/window_session_controller.h" #include "window/window_session_controller_link_info.h" +#include +#include #include #include +#include + namespace Iv { namespace { @@ -278,6 +284,119 @@ private: }; +struct MarkdownMessageContext { + ClickHandlerContext clickHandlerContext; + base::weak_ptr sessionWindow; +}; + +struct LocalMarkdownTarget { + QString key; + QString path; + QString sourceName; + QString fragment; +}; + +[[nodiscard]] QString NormalizeLocalMarkdownFragment(QString fragment) { + fragment = QString::fromUtf8( + QByteArray::fromPercentEncoding(fragment.toUtf8())); + fragment = fragment.trimmed().toLower(); + while (fragment.startsWith(QChar('#'))) { + fragment.remove(0, 1); + } + return fragment; +} + +[[nodiscard]] LocalMarkdownTarget ParseLocalMarkdownTarget(QString path) { + auto sourcePath = path; + auto fragment = QString(); + if (!QFileInfo(sourcePath).exists()) { + const auto hash = sourcePath.lastIndexOf(QChar('#')); + const auto candidate = (hash > 0) ? sourcePath.mid(0, hash) : QString(); + if (!candidate.isEmpty() && QFileInfo(candidate).exists()) { + fragment = NormalizeLocalMarkdownFragment(sourcePath.mid(hash + 1)); + sourcePath = candidate; + } + } + const auto info = QFileInfo(sourcePath); + if (!info.exists()) { + return { + .key = path, + .path = std::move(path), + }; + } + auto result = LocalMarkdownTarget{ + .key = info.absoluteFilePath(), + .path = info.absoluteFilePath(), + .sourceName = info.fileName(), + .fragment = std::move(fragment), + }; + if (!result.fragment.isEmpty()) { + result.path += u"#"_q + result.fragment; + } + return result; +} + +[[nodiscard]] auto ExtractMarkdownMessageContext(const QVariant &context) { + if (!context.isValid() || !context.canConvert()) { + return std::optional(); + } + const auto clickHandlerContext = context.value(); + return std::make_optional(MarkdownMessageContext{ + .clickHandlerContext = clickHandlerContext, + .sessionWindow = clickHandlerContext.sessionWindow, + }); +} + +[[nodiscard]] Main::Session *ResolveMarkdownSession( + const MarkdownMessageContext &context) { + if (const auto controller = context.sessionWindow.get()) { + return &controller->session(); + } + return nullptr; +} + +[[nodiscard]] HistoryItem *ResolveMarkdownItem( + const MarkdownMessageContext &context) { + const auto session = ResolveMarkdownSession(context); + const auto itemId = context.clickHandlerContext.itemId; + return (session && itemId) ? session->data().message(itemId) : nullptr; +} + +[[nodiscard]] bool CanShareMarkdownItem(not_null item) { + const auto peer = item->history()->peer; + return peer->allowsForwarding() && !item->forbidsForward(); +} + +[[nodiscard]] Markdown::OpenOptions PrepareLocalMarkdownOptions( + QVariant context) { + auto options = Markdown::OpenOptions{ + .viewerKind = Markdown::ViewerKind::LocalFile, + .clickHandlerContext = std::move(context), + }; + const auto messageContext = ExtractMarkdownMessageContext( + options.clickHandlerContext); + const auto item = messageContext + ? ResolveMarkdownItem(*messageContext) + : nullptr; + if (item && CanShareMarkdownItem(not_null{ item })) { + options.share = [context = *messageContext]( + std::shared_ptr show) { + const auto session = ResolveMarkdownSession(context); + const auto itemId = context.clickHandlerContext.itemId; + const auto current = (session && itemId) + ? session->data().message(itemId) + : nullptr; + if (!show || !current || !CanShareMarkdownItem(not_null{ current })) { + return; + } + FastShareMessage( + Main::MakeSessionShow(show, not_null{ session }), + not_null{ current }); + }; + } + return options; +} + } // namespace class Shown final : public base::has_weak_ptr { @@ -322,7 +441,7 @@ private: }; struct FileStream { not_null document; - std::unique_ptr loader; + std::unique_ptr<::Media::Streaming::Loader> loader; std::vector requests; std::string mime; rpl::lifetime lifetime; @@ -339,6 +458,9 @@ private: QString title, QString initialFragment, not_null page); + [[nodiscard]] Markdown::OpenOptions markdownOpenOptions( + QString initialFragment, + not_null page) const; void showWindowed(Prepared result, Source source, bool refresh); void showHtmlWindowed(Prepared result, bool refresh); @@ -352,7 +474,8 @@ private: not_null page) const; [[nodiscard]] bool activateMarkdownMedia( const Markdown::MediaActivation &activation, - Qt::MouseButton button) const; + Qt::MouseButton button, + const QVariant &clickHandlerContext) const; [[nodiscard]] ::Data::FileOrigin fileOrigin( not_null page) const; @@ -361,10 +484,10 @@ private: void streamFile(FileStream &file, Webview::DataRequest request); void processPartInFile( FileStream &file, - Media::Streaming::LoadedPart &&part); + ::Media::Streaming::LoadedPart &&part); bool finishRequestWithPart( PartRequest &request, - const Media::Streaming::LoadedPart &part); + const ::Media::Streaming::LoadedPart &part); void streamMap(QString params, Webview::DataRequest request); void sendEmbed(QByteArray hash, Webview::DataRequest request); @@ -640,6 +763,33 @@ void Shown::createController() { }, _controller->lifetime()); } +Markdown::OpenOptions Shown::markdownOpenOptions( + QString initialFragment, + not_null page) const { + const auto clickHandlerContext = std::make_shared(); + auto options = Markdown::OpenOptions{ + .sourceName = page->displayedSiteName(), + .sourceUrl = page->url, + .initialFragment = std::move(initialFragment), + .viewerKind = Markdown::ViewerKind::InstantView, + .clickHandlerContextRef = clickHandlerContext, + .activateMedia = [=]( + const Markdown::MediaActivation &activation, + Qt::MouseButton button) { + return activateMarkdownMedia(activation, button, *clickHandlerContext); + }, + }; + if (!page->url.isEmpty()) { + options.share = [=, url = page->url](std::shared_ptr show) { + if (!show) { + return; + } + FastShareLink(Main::MakeSessionShow(show, _session), url); + }; + } + return options; +} + void Shown::createMarkdownController( Markdown::MarkdownArticleContent content, QString title, @@ -647,14 +797,7 @@ void Shown::createMarkdownController( not_null page) { Expects(!_markdownController); - auto options = Markdown::OpenOptions{ - .initialFragment = std::move(initialFragment), - .activateMedia = [=]( - const Markdown::MediaActivation &activation, - Qt::MouseButton button) { - return activateMarkdownMedia(activation, button); - }, - }; + auto options = markdownOpenOptions(std::move(initialFragment), page); _markdownController = std::make_unique( _delegate, std::move(content), @@ -717,15 +860,8 @@ void Shown::showMarkdownWindowed( QString initialFragment, not_null page) { _controller = nullptr; + auto options = markdownOpenOptions(std::move(initialFragment), page); if (_markdownController) { - auto options = Markdown::OpenOptions{ - .initialFragment = std::move(initialFragment), - .activateMedia = [=]( - const Markdown::MediaActivation &activation, - Qt::MouseButton button) { - return activateMarkdownMedia(activation, button); - }, - }; _markdownController->update( std::move(content), std::move(title), @@ -746,7 +882,8 @@ std::shared_ptr Shown::createMediaRuntime( bool Shown::activateMarkdownMedia( const Markdown::MediaActivation &activation, - Qt::MouseButton button) const { + Qt::MouseButton button, + const QVariant &clickHandlerContext) const { if (button != Qt::LeftButton && button != Qt::MiddleButton) { return false; } @@ -757,7 +894,7 @@ bool Shown::activateMarkdownMedia( if (activation.url.isEmpty()) { return false; } - HiddenUrlClickHandler::Open(activation.url); + HiddenUrlClickHandler::Open(activation.url, clickHandlerContext); return true; case Markdown::MediaActivationKind::Photo: if (!activation.photo) { @@ -862,7 +999,7 @@ void Shown::streamFile( }).first->second; file.loader->parts( - ) | rpl::on_next([=](Media::Streaming::LoadedPart &&part) { + ) | rpl::on_next([=](::Media::Streaming::LoadedPart &&part) { const auto i = _streams.find(documentId); Assert(i != end(_streams)); processPartInFile(i->second, std::move(part)); @@ -872,7 +1009,7 @@ void Shown::streamFile( } void Shown::streamFile(FileStream &file, Webview::DataRequest request) { - constexpr auto kPart = Media::Streaming::Loader::kPartSize; + constexpr auto kPart = ::Media::Streaming::Loader::kPartSize; const auto size = file.document->size; const auto last = int((size + kPart - 1) / kPart); const auto from = int(std::min(int64(request.offset), size) / kPart); @@ -938,7 +1075,7 @@ QByteArray Shown::readFile( void Shown::processPartInFile( FileStream &file, - Media::Streaming::LoadedPart &&part) { + ::Media::Streaming::LoadedPart &&part) { for (auto i = begin(file.requests); i != end(file.requests);) { if (finishRequestWithPart(*i, part)) { auto done = base::take(*i); @@ -957,16 +1094,16 @@ void Shown::processPartInFile( bool Shown::finishRequestWithPart( PartRequest &request, - const Media::Streaming::LoadedPart &part) { + const ::Media::Streaming::LoadedPart &part) { const auto offset = part.offset; - if (offset == Media::Streaming::LoadedPart::kFailedOffset) { + if (offset == ::Media::Streaming::LoadedPart::kFailedOffset) { request.data = QByteArray(); return true; } else if (offset < request.offset || offset >= request.offset + request.data.size()) { return false; } - constexpr auto kPart = Media::Streaming::Loader::kPartSize; + constexpr auto kPart = ::Media::Streaming::Loader::kPartSize; const auto copy = std::min( int(part.bytes.size()), int(request.data.size() - (offset - request.offset))); @@ -1493,20 +1630,30 @@ void Instance::showTonSite( bool Instance::showMarkdown( const QString &path, QVariant context) { - auto i = _markdowns.find(path); + const auto target = ParseLocalMarkdownTarget(path); + auto options = PrepareLocalMarkdownOptions(context); + if (!target.sourceName.isEmpty()) { + options.sourceName = target.sourceName; + options.sourcePath = target.key; + } + options.initialFragment = target.fragment; + auto i = _markdowns.find(target.key); if (i == end(_markdowns)) { - if (auto controller = Markdown::TryOpenLocalFile(_delegate, path)) { + if (auto controller = Markdown::TryOpenLocalFile( + _delegate, + target.path, + std::move(options))) { controller->events() | rpl::on_next([=](Markdown::Event event) { using Type = Markdown::Event::Type; switch (event.type) { case Type::Close: - _markdowns.take(path); + _markdowns.take(target.key); break; case Type::Quit: Shortcuts::Launch(Shortcuts::Command::Quit); break; case Type::OpenFile: - if (!showMarkdown(event.url)) { + if (!showMarkdown(event.url, event.context)) { DEBUG_LOG(("Native Markdown IV: " "failed local markdown link: %1" ).arg(event.url)); @@ -1515,10 +1662,12 @@ bool Instance::showMarkdown( } }, controller->lifetime()); - i = _markdowns.emplace(path, std::move(controller)).first; + i = _markdowns.emplace(target.key, std::move(controller)).first; } else { return false; } + } else { + i->second->updateOptions(std::move(options)); } i->second->activate(); return true; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp index e4c37fc376..fa208353b7 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp @@ -24,6 +24,13 @@ constexpr auto kCodeTabColumns = 4; constexpr auto kCodeTrailingGuard = 0x2060; const auto kPhotoCopyLabel = u"Photo"_q; +[[nodiscard]] int SingleDigitOrderedMarkerWidth( + const style::Markdown &markdown) { + return std::max( + markdown.body.font->width(u"8."_q), + markdown.body.font->width(u"8)"_q)); +} + [[nodiscard]] QString CodeBlockDisplayText(const QString &text) { auto result = QString(); result.reserve(text.size()); @@ -210,8 +217,9 @@ QPoint BulletMarkerCenter( const style::Markdown &markdown) { const auto &list = markdown.list; const auto lineHeight = TextLineHeight(markdown.body); + const auto markerWidth = SingleDigitOrderedMarkerWidth(markdown); return QPoint( - left + list.markerWidth - list.bulletLeftShift - (lineHeight / 2), + left + list.markerWidth - list.bulletLeftShift - ((markerWidth + 1) / 2), top + (lineHeight / 2)); } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h index 800e057c35..406fe4b2c4 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h @@ -2,12 +2,14 @@ #include #include +#include #include #include namespace Ui { class DynamicImage; +class Show; } // namespace Ui namespace Iv { @@ -53,11 +55,23 @@ struct MediaActivation { std::shared_ptr photo; }; +enum class ViewerKind { + Auto, + LocalFile, + InstantView, +}; + struct OpenOptions { QString sourceName; QString sourcePath; + QString sourceUrl; QString initialFragment; + ViewerKind viewerKind = ViewerKind::Auto; Iv::Delegate *delegate = nullptr; + QVariant clickHandlerContext; + std::shared_ptr clickHandlerContextRef; + std::function openSource; + std::function)> share; std::function activateMedia; }; @@ -77,6 +91,7 @@ struct Event { }; Type type = Type::Close; QString url; + QVariant context; }; } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp index a73fd10b66..649135a97f 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp @@ -1,18 +1,34 @@ #include "iv/markdown/iv_markdown_controller.h" +#include "base/event_filter.h" +#include "base/weak_ptr.h" +#include "core/credits_amount.h" +#include "core/click_handler_types.h" #include "iv/markdown/iv_markdown_parse.h" #include "iv/markdown/iv_markdown_view.h" #include "iv/iv_delegate_impl.h" +#include "core/file_utilities.h" +#include "lang/lang_keys.h" #include "logs.h" +#include "ui/layers/layer_manager.h" +#include "ui/layers/show.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/labels.h" +#include "ui/widgets/popup_menu.h" #include "ui/widgets/rp_window.h" +#include "ui/wrap/fade_wrap.h" +#include "styles/style_iv.h" +#include "styles/style_menu_icons.h" #include "styles/palette.h" #include "styles/style_window.h" #include #include #include +#include #include +#include #include #include @@ -30,12 +46,80 @@ constexpr auto kZoomStep = int(10); not_null delegate, const QString &title) { options.delegate = delegate; - if (options.sourceName.isEmpty()) { - options.sourceName = title; - } + Q_UNUSED(title); return options; } +[[nodiscard]] ViewerKind ResolveViewerKind(const OpenOptions &options) { + return (options.viewerKind != ViewerKind::Auto) + ? options.viewerKind + : options.sourcePath.isEmpty() + ? ViewerKind::InstantView + : ViewerKind::LocalFile; +} + +[[nodiscard]] QString SubtitleText( + const OpenOptions &options, + const QString &title) { + if (!options.sourceName.trimmed().isEmpty()) { + return options.sourceName.trimmed(); + } + const auto host = QUrl(options.sourceUrl).host().trimmed(); + return !host.isEmpty() ? host : title.trimmed(); +} + +[[nodiscard]] QString OpenSourceLabel(ViewerKind kind) { + return (kind == ViewerKind::InstantView) + ? tr::lng_iv_open_in_browser(tr::now) + : tr::lng_markdown_preview_open_file(tr::now); +} + +[[nodiscard]] const style::icon *OpenSourceIcon(ViewerKind kind) { + return (kind == ViewerKind::InstantView) + ? &st::menuIconIpAddress + : &st::menuIconFile; +} + +[[nodiscard]] QVariant ExtendClickHandlerContext( + QVariant context, + const std::shared_ptr &show) { + if (!show) { + return context; + } else if (!context.isValid() + || context.canConvert()) { + auto clickContext = context.isValid() + ? context.value() + : ClickHandlerContext(); + clickContext.show = show; + return QVariant::fromValue(clickContext); + } + return context; +} + +[[nodiscard]] std::shared_ptr ResolveClickHandlerContextRef( + const std::shared_ptr ¤t, + const OpenOptions &options) { + return options.clickHandlerContextRef + ? options.clickHandlerContextRef + : (current ? current : std::make_shared()); +} + +void ProcessZoomShortcut(not_null delegate, QKeyEvent *event) { + if (!(event->modifiers() & Qt::ControlModifier)) { + return; + } + if (event->key() == Qt::Key_Plus || event->key() == Qt::Key_Equal) { + event->accept(); + delegate->ivSetZoom(delegate->ivZoom() + kZoomStep); + } else if (event->key() == Qt::Key_Minus) { + event->accept(); + delegate->ivSetZoom(delegate->ivZoom() - kZoomStep); + } else if (event->key() == Qt::Key_0) { + event->accept(); + delegate->ivSetZoom(0); + } +} + struct OpenTarget { QString path; QString fragment; @@ -155,6 +239,10 @@ Controller::Controller( , _title(std::move(title)) , _renderer(nullptr) , _options(PrepareOpenOptions(std::move(options), delegate, _title)) { + _clickHandlerContextRef = ResolveClickHandlerContextRef( + _clickHandlerContextRef, + _options); + _options.clickHandlerContextRef = _clickHandlerContextRef; createWindow(); } @@ -169,6 +257,10 @@ Controller::Controller( , _title(std::move(title)) , _renderer(renderer ? std::move(renderer) : std::make_shared()) , _options(PrepareOpenOptions(std::move(options), delegate, _title)) { + _clickHandlerContextRef = ResolveClickHandlerContextRef( + _clickHandlerContextRef, + _options); + _options.clickHandlerContextRef = _clickHandlerContextRef; createWindow(); } @@ -199,16 +291,68 @@ void Controller::update( _preparedContent = std::move(content); _title = std::move(title); _options = PrepareOpenOptions(std::move(options), _delegate, _title); - if (_window) { - _window->setTitle(_title); - _window->setWindowTitle(_title); + _clickHandlerContextRef = ResolveClickHandlerContextRef( + _clickHandlerContextRef, + _options); + _options.clickHandlerContextRef = _clickHandlerContextRef; + if (_menu) { + _menu = nullptr; + _menuToggle->setForceRippled(false); } + refreshTitle(); createPreview(); if (_window && _window->isActiveWindow() && _preview) { _preview->setFocus(); } } +void Controller::updateOptions(OpenOptions options) { + const auto initialFragment = options.initialFragment; + auto refreshed = PrepareOpenOptions(std::move(options), _delegate, _title); + if (refreshed.sourceName.isEmpty()) { + refreshed.sourceName = _options.sourceName; + } + if (refreshed.sourcePath.isEmpty()) { + refreshed.sourcePath = _options.sourcePath; + } + if (refreshed.sourceUrl.isEmpty()) { + refreshed.sourceUrl = _options.sourceUrl; + } + if (refreshed.viewerKind == ViewerKind::Auto) { + refreshed.viewerKind = _options.viewerKind; + } + if (!refreshed.openSource) { + refreshed.openSource = _options.openSource; + } + if (!refreshed.activateMedia) { + refreshed.activateMedia = _options.activateMedia; + } + _options = std::move(refreshed); + if (!_clickHandlerContextRef) { + _clickHandlerContextRef = std::make_shared(); + } + _options.clickHandlerContextRef = _clickHandlerContextRef; + if (_clickHandlerContextRef) { + *_clickHandlerContextRef = ExtendClickHandlerContext( + _options.clickHandlerContext, + _show); + } + if (_menu) { + _menu = nullptr; + _menuToggle->setForceRippled(false); + } + refreshTitle(); + if (!initialFragment.isEmpty() && _preview) { + const auto scrolled = ScrollMarkdownPreviewToAnchor( + _preview.get(), + initialFragment); + static_cast(scrolled); + } + if (_window && _window->isActiveWindow() && _preview) { + _preview->setFocus(); + } +} + bool Controller::active() const { return _window && _window->isActiveWindow(); } @@ -223,11 +367,122 @@ void Controller::close() { _events.fire({ Event::Type::Close }); } -void Controller::createPreview() { - if (!_window) { +ViewerKind Controller::viewerKind() const { + return ResolveViewerKind(_options); +} + +QString Controller::subtitleText() const { + return SubtitleText(_options, _title); +} + +bool Controller::canOpenSource() const { + if (_options.openSource) { + return true; + } + return (viewerKind() == ViewerKind::InstantView) + ? !_options.sourceUrl.isEmpty() + : !_options.sourcePath.isEmpty(); +} + +bool Controller::canShare() const { + return static_cast(_options.share); +} + +void Controller::refreshTitle() { + if (_window) { + _window->setTitle(_title); + _window->setWindowTitle(_title); + } + if (_subtitle) { + _subtitle->setText(subtitleText()); + updateTitleGeometry(_window->body()->width()); + } +} + +void Controller::updateTitleGeometry(int newWidth) const { + _subtitleWrap->setGeometry(0, 0, newWidth, st::ivSubtitleHeight); + _subtitle->resizeToWidth(newWidth + - st::ivSubtitleLeft + - _menuToggle->width()); + _subtitle->moveToLeft(st::ivSubtitleLeft, st::ivSubtitleTop); + _menuToggle->moveToRight(0, 0); + if (_titleShadow) { + _titleShadow->resizeToWidth(newWidth); + _titleShadow->move(0, st::ivSubtitleHeight); + } +} + +void Controller::openSource() { + if (_options.openSource) { + _options.openSource(); + } else if (viewerKind() == ViewerKind::InstantView) { + File::OpenUrl(_options.sourceUrl); + } else { + File::Launch(_options.sourcePath); + } +} + +void Controller::showMenu() { + if (!_window || _menu) { return; } - const auto parent = _window->body(); + _menu = base::make_unique_q( + _window.get(), + st::popupMenuWithIcons); + _menu->setDestroyedCallback(crl::guard(_window.get(), [ + this, + menu = _menu.get()] { + if (_menu == menu) { + _menuToggle->setForceRippled(false); + } + })); + _menuToggle->setForceRippled(true); + + const auto action = _menu->addAction( + OpenSourceLabel(viewerKind()), + crl::guard(_window.get(), [=] { + openSource(); + }), + OpenSourceIcon(viewerKind())); + action->setEnabled(canOpenSource()); + + if (canShare()) { + _menu->addAction( + tr::lng_iv_share(tr::now), + crl::guard(_window.get(), [=, share = _options.share] { + share(_show); + }), + &st::menuIconShare); + } + + _menu->setForcedOrigin(Ui::PanelAnimation::Origin::TopRight); + _menu->popup(_window->body()->mapToGlobal( + QPoint(_window->body()->width(), 0) + st::ivMenuPosition)); +} + +void Controller::createLayerManager() { + if (!_window || _layerManager) { + return; + } + _layerManager = std::make_unique( + not_null{ _window->body().get() }); + _layerManager->setHideByBackgroundClick(false); + _show = _layerManager->uiShow(); +} + +void Controller::createPreview() { + if (!_container) { + return; + } + const auto parent = _container; + auto options = _options; + options.clickHandlerContextRef = _clickHandlerContextRef; + options.clickHandlerContext = ExtendClickHandlerContext( + std::move(options.clickHandlerContext), + _show); + if (options.clickHandlerContextRef) { + *options.clickHandlerContextRef = options.clickHandlerContext; + } const auto callback = [=](Event event) { _events.fire(std::move(event)); }; @@ -238,23 +493,48 @@ void Controller::createPreview() { std::move(*_preparedContent), _renderer, callback, - _options) + options) : CreateMarkdownPreviewWidget( parent, *_document, callback, - _options); + options); _preparedContent.reset(); _preview->setGeometry(parent->rect()); parent->sizeValue() | rpl::on_next([=](QSize size) { _preview->resize(size); }, _preview->lifetime()); + if (_titleShadow) { + MarkdownPreviewScrollTopValue( + _preview.get() + ) | rpl::on_next([=](int scrollTop) { + _titleShadow->toggle( + (scrollTop > 0), + anim::type::normal); + }, _preview->lifetime()); + } _preview->show(); } void Controller::createWindow() { _window = std::make_unique(); const auto window = _window.get(); + _subtitleWrap = std::make_unique(window->body().get()); + _subtitle = std::make_unique( + _subtitleWrap.get(), + subtitleText(), + st::ivSubtitle); + _subtitle->setSelectable(true); + _menuToggle.create(_subtitleWrap.get(), st::ivMenuToggle); + _menuToggle->setClickedCallback([=] { + showMenu(); + }); + _subtitleWrap->paintRequest() | rpl::on_next([=](QRect clip) { + QPainter(_subtitleWrap.get()).fillRect(clip, st::windowBg); + }, _subtitleWrap->lifetime()); + window->body()->widthValue() | rpl::on_next([=](int width) { + updateTitleGeometry(width); + }, _subtitle->lifetime()); window->setTitle(_title); window->setWindowTitle(_title); window->setGeometry(_delegate->ivGeometry(window)); @@ -267,43 +547,58 @@ void Controller::createWindow() { window->body()->paintRequest() | rpl::on_next([=](QRect clip) { QPainter(window->body().get()).fillRect(clip, st::windowBg); }, window->body()->lifetime()); + _container = Ui::CreateChild(window->body().get()); + rpl::combine( + window->body()->sizeValue(), + _subtitleWrap->heightValue() + ) | rpl::on_next([=](QSize size, int titleHeight) { + _container->setGeometry(QRect(QPoint(), size).marginsRemoved( + { 0, titleHeight, 0, 0 })); + }, _container->lifetime()); + _container->paintRequest() | rpl::on_next([=](QRect clip) { + QPainter(_container).fillRect(clip, st::windowBg); + }, _container->lifetime()); + _titleShadow.create(window->body().get()); + updateTitleGeometry(window->body()->width()); + createLayerManager(); createPreview(); + _container->show(); window->events() | rpl::on_next([=](not_null e) { if (e->type() == QEvent::Close) { close(); } else if (e->type() == QEvent::KeyPress) { const auto event = static_cast(e.get()); - if (event->modifiers() & Qt::ControlModifier) { - if (event->key() == Qt::Key_Plus - || event->key() == Qt::Key_Equal) { - event->accept(); - _delegate->ivSetZoom(_delegate->ivZoom() + kZoomStep); - return; - } else if (event->key() == Qt::Key_Minus) { - event->accept(); - _delegate->ivSetZoom(_delegate->ivZoom() - kZoomStep); - return; - } else if (event->key() == Qt::Key_0) { - event->accept(); - _delegate->ivSetZoom(0); - return; - } - } if (event->key() == Qt::Key_Escape) { event->accept(); close(); } } }, window->lifetime()); + base::install_event_filter(window, qApp, [=](not_null e) { + if (e->type() != QEvent::ShortcutOverride || !window->isActiveWindow()) { + return base::EventFilterResult::Continue; + } + const auto event = static_cast(e.get()); + if (event->matches(QKeySequence::Close)) { + close(); + return base::EventFilterResult::Cancel; + } + const auto previousAccepted = event->isAccepted(); + ProcessZoomShortcut(_delegate, event); + return event->isAccepted() && !previousAccepted + ? base::EventFilterResult::Cancel + : base::EventFilterResult::Continue; + }); window->show(); } std::unique_ptr TryOpenLocalFile( not_null delegate, - const QString &path) { + const QString &path, + OpenOptions options) { const auto &limits = ParseLimitsForIv(); const auto target = ParseOpenTarget(path); @@ -360,9 +655,14 @@ std::unique_ptr TryOpenLocalFile( ).arg(parsed - validated ).arg(target.path)); - auto options = OpenOptions(); + if (options.sourceName.isEmpty()) { + options.sourceName = source.name; + } options.sourcePath = std::move(source.path); options.initialFragment = std::move(target.fragment); + if (options.viewerKind == ViewerKind::Auto) { + options.viewerKind = ViewerKind::LocalFile; + } return std::make_unique( delegate, std::move(parseResult.document), diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h index 097b11fd13..933237c886 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h @@ -1,12 +1,23 @@ #pragma once +#include "base/object_ptr.h" +#include "base/unique_qptr.h" #include "iv/markdown/iv_markdown_document.h" #include "iv/markdown/iv_markdown_prepare.h" #include "ui/widgets/rp_window.h" #include - #include +#include + +namespace Ui { +class FlatLabel; +class IconButton; +class LayerManager; +class PopupMenu; +class Show; +class FadeShadow; +} // namespace Ui namespace Iv::Markdown { @@ -30,6 +41,7 @@ public: MarkdownArticleContent content, QString title, OpenOptions options = {}); + void updateOptions(OpenOptions options = {}); [[nodiscard]] bool active() const; void minimize(); @@ -43,7 +55,16 @@ public: private: void close(); void createWindow(); + void createLayerManager(); void createPreview(); + void updateTitleGeometry(int newWidth) const; + void showMenu(); + void openSource(); + [[nodiscard]] ViewerKind viewerKind() const; + [[nodiscard]] QString subtitleText() const; + [[nodiscard]] bool canOpenSource() const; + [[nodiscard]] bool canShare() const; + void refreshTitle(); const not_null _delegate; @@ -52,7 +73,16 @@ private: QString _title; const std::shared_ptr _renderer; OpenOptions _options; + std::shared_ptr _clickHandlerContextRef; std::unique_ptr _window; + std::unique_ptr _subtitleWrap; + std::unique_ptr _subtitle; + object_ptr _menuToggle = { nullptr }; + object_ptr _titleShadow = { nullptr }; + base::unique_qptr _menu; + Ui::RpWidget *_container = nullptr; + std::unique_ptr _layerManager; + std::shared_ptr _show; std::unique_ptr _preview; rpl::event_stream _events; @@ -63,6 +93,7 @@ private: [[nodiscard]] std::unique_ptr TryOpenLocalFile( not_null delegate, - const QString &path); + const QString &path, + OpenOptions options = {}); } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_blocks.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_blocks.cpp index b27dee32e1..bce62dc48e 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_blocks.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_blocks.cpp @@ -37,6 +37,18 @@ constexpr auto kMaxVisualQuoteDepth = 3; return std::max(int((numerator + denominator - 1) / denominator), 1); } +[[nodiscard]] QString StripOneTrailingNewline(QString text) { + if (text.endsWith(u"\r\n"_q)) { + text.chop(2); + } else if (!text.isEmpty()) { + const auto last = text.back(); + if ((last == QChar(u'\n')) || (last == QChar(u'\r'))) { + text.chop(1); + } + } + return text; +} + [[nodiscard]] int FlowFormulaTextSize( PreparedBlockKind kind, int headingLevel, @@ -300,7 +312,7 @@ void AppendRichBlock( [[nodiscard]] PreparedBlock PrepareCodeBlock(const MarkdownNode &node) { auto block = PreparedBlock(); block.kind = PreparedBlockKind::CodeBlock; - block.text.text = node.text; + block.text.text = StripOneTrailingNewline(node.text); block.codeLanguage = FirstInfoToken(node.info); return block; } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp index aef1eac113..76c2654d40 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp @@ -45,6 +45,18 @@ void SortPreparedIvRichText(PreparedIvRichText *text) { SortEntities(&text->text); } +[[nodiscard]] QString StripOneTrailingNewline(QString text) { + if (text.endsWith(u"\r\n"_q)) { + text.chop(2); + } else if (!text.isEmpty()) { + const auto last = text.back(); + if ((last == QChar(u'\n')) || (last == QChar(u'\r'))) { + text.chop(1); + } + } + return text; +} + [[nodiscard]] PreparedBlock EmptyParagraphBlock() { auto block = PreparedBlock(); block.kind = PreparedBlockKind::Paragraph; @@ -456,7 +468,7 @@ void SortPreparedIvRichText(PreparedIvRichText *text) { block.kind = PreparedBlockKind::CodeBlock; block.anchorId = std::move(anchorId); block.codeLanguage = qs(data.vlanguage()).trimmed(); - block.text.text = prepared.text.text; + block.text.text = StripOneTrailingNewline(prepared.text.text); result->push_back(std::move(block)); return true; }, [&](const MTPDpageBlockFooter &data) { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp index 326a934b14..209ca9b53a 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp @@ -53,6 +53,12 @@ namespace { : PrepareTerminalFailureName(failure.terminal); } +[[nodiscard]] QVariant CurrentClickHandlerContext(const OpenOptions &options) { + return options.clickHandlerContextRef + ? *options.clickHandlerContextRef + : options.clickHandlerContext; +} + } // namespace class MarkdownPreviewRoot final : public Ui::RpWidget { @@ -66,15 +72,16 @@ public: QWidget *parent, MarkdownArticleContent content, std::shared_ptr renderer, - Fn callback, - const OpenOptions &options); + Fn callback, + const OpenOptions &options); + bool scrollToAnchor(const QString &anchorId); + [[nodiscard]] rpl::producer scrollTopValue() const; private: void setup(); void prepareArticle(); void activateLink(const PreparedLink &link, Qt::MouseButton button); void applyPreparedContent(MarkdownArticleContent prepared, int prepareMs); - [[nodiscard]] bool scrollToAnchor(const QString &anchorId); void updateBodyVisibleTopBottom(); void updateChildrenGeometry(QSize size); void updateFailureGeometry(); @@ -141,6 +148,9 @@ void MarkdownPreviewRoot::setup() { _scroll->hide(); if (_body) { _body->hide(); + _body->setClickHandlerContext( + CurrentClickHandlerContext(_options), + _options.clickHandlerContextRef); _body->setLinkActivationCallback([=]( const PreparedLink &link, Qt::MouseButton button) { @@ -234,7 +244,9 @@ void MarkdownPreviewRoot::activateLink( } switch (link.kind) { case PreparedLinkKind::External: - HiddenUrlClickHandler::Open(link.target); + HiddenUrlClickHandler::Open( + link.target, + CurrentClickHandlerContext(_options)); break; case PreparedLinkKind::Anchor: case PreparedLinkKind::Footnote: @@ -252,6 +264,7 @@ void MarkdownPreviewRoot::activateLink( _callback({ .type = Event::Type::OpenFile, .url = std::move(target), + .context = CurrentClickHandlerContext(_options), }); } break; case PreparedLinkKind::RejectedRelative: @@ -332,6 +345,24 @@ bool MarkdownPreviewRoot::scrollToAnchor(const QString &anchorId) { return true; } +rpl::producer MarkdownPreviewRoot::scrollTopValue() const { + return _scroll + ? _scroll->scrollTopValue() + : rpl::single(0); +} + +bool ScrollMarkdownPreviewToAnchor( + Ui::RpWidget *preview, + const QString &anchorId) { + const auto root = dynamic_cast(preview); + return root ? root->scrollToAnchor(anchorId) : false; +} + +rpl::producer MarkdownPreviewScrollTopValue(Ui::RpWidget *preview) { + const auto root = dynamic_cast(preview); + return root ? root->scrollTopValue() : rpl::single(0); +} + void MarkdownPreviewRoot::updateBodyVisibleTopBottom() { if (_body) { const auto scrollTop = _scroll->scrollTop(); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.h index 14f9e29b35..58e87e7451 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.h @@ -22,5 +22,10 @@ namespace Iv::Markdown { std::shared_ptr renderer, Fn callback, const OpenOptions &options = {}); +bool ScrollMarkdownPreviewToAnchor( + Ui::RpWidget *preview, + const QString &anchorId); +[[nodiscard]] rpl::producer MarkdownPreviewScrollTopValue( + Ui::RpWidget *preview); } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.cpp index cd6e59534f..6795d9e1ff 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.cpp @@ -1,5 +1,9 @@ #include "iv/markdown/iv_markdown_view_widget.h" +#include "base/weak_ptr.h" +#include "core/credits_amount.h" +#include "core/click_handler_types.h" + #include #include #include @@ -10,9 +14,9 @@ #include #include -#include "base/weak_ptr.h" #include "core/file_utilities.h" #include "lang/lang_keys.h" +#include "ui/layers/show.h" #include "ui/chat/chat_style.h" #include "ui/integration.h" #include "ui/widgets/popup_menu.h" @@ -114,6 +118,13 @@ void MarkdownDocumentWidget::setMediaActivationCallback( _activateMedia = std::move(callback); } +void MarkdownDocumentWidget::setClickHandlerContext( + QVariant context, + std::shared_ptr contextRef) { + _clickHandlerContext = std::move(context); + _clickHandlerContextRef = std::move(contextRef); +} + void MarkdownDocumentWidget::setArticle( std::shared_ptr article) { ClickHandler::clearActive(this); @@ -275,8 +286,9 @@ void MarkdownDocumentWidget::contextMenuEvent(QContextMenuEvent *e) { } else if (!contextText.empty()) { _contextMenu->addAction( tr::lng_context_copy_text(tr::now), - [text = contextText] { + [text = contextText, this] { TextUtilities::SetClipboardText(text); + showToast(tr::lng_text_copied(tr::now)); }, &st::menuIconCopy); } @@ -549,9 +561,23 @@ TextForMimeData MarkdownDocumentWidget::getSelectedText() const { : TextForMimeData(); } +QVariant MarkdownDocumentWidget::clickHandlerContext() const { + return _clickHandlerContextRef + ? *_clickHandlerContextRef + : _clickHandlerContext; +} + +void MarkdownDocumentWidget::showToast(const QString &text) const { + const auto context = clickHandlerContext().value(); + if (context.show) { + context.show->showToast(text); + } +} + void MarkdownDocumentWidget::copySelectedText() { if (const auto text = getSelectedText(); !text.empty()) { TextUtilities::SetClipboardText(text); + showToast(tr::lng_text_copied(tr::now)); } } @@ -765,7 +791,31 @@ MarkdownArticleHitTestResult MarkdownDocumentWidget::dragActionFinish( if (state.preparedLink && _activateLink) { _activateLink(*state.preparedLink, button); } else { - ActivateClickHandler(window(), activated, button); + auto clickHandlerContext = this->clickHandlerContext(); + if (std::dynamic_pointer_cast(activated)) { + const auto context = clickHandlerContext.value(); + if (context.show) { + auto sanitized = ClickHandlerContext(); + sanitized.itemId = context.itemId; + sanitized.elementDelegate = context.elementDelegate; + sanitized.botWebviewContext = context.botWebviewContext; + sanitized.show = context.show; + sanitized.mayShowConfirmation = context.mayShowConfirmation; + sanitized.skipBotAutoLogin = context.skipBotAutoLogin; + sanitized.botStartAutoSubmit = context.botStartAutoSubmit; + sanitized.ignoreIv = context.ignoreIv; + sanitized.dark = context.dark; + sanitized.peer = context.peer; + clickHandlerContext = QVariant::fromValue(sanitized); + const auto handled = Ui::Integration::Instance().copyPreOnClick( + clickHandlerContext); + static_cast(handled); + } + } + auto context = ClickContext(); + context.button = button; + context.other = std::move(clickHandlerContext); + ActivateClickHandler(window(), activated, context); } } else if ((button == Qt::LeftButton || button == Qt::MiddleButton) && state.mediaActivation.kind != MediaActivationKind::None diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.h index f7160cb6f5..2e9e4fe31a 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.h @@ -10,6 +10,7 @@ #include #include +#include namespace Ui { class PopupMenu; @@ -31,6 +32,9 @@ public: std::function callback); void setMediaActivationCallback( std::function callback); + void setClickHandlerContext( + QVariant context, + std::shared_ptr contextRef = nullptr); void setArticle(std::shared_ptr article); void setZoom(int value); void refreshPalette(); @@ -77,6 +81,8 @@ private: [[nodiscard]] MarkdownArticleSelection selectionFromHit( const MarkdownArticleHitTestResult &result) const; [[nodiscard]] TextForMimeData getSelectedText() const; + [[nodiscard]] QVariant clickHandlerContext() const; + void showToast(const QString &text) const; void copySelectedText(); void syncArticleVisibleTopBottom(); @@ -102,6 +108,8 @@ private: std::unique_ptr _blockquotePaintCache; std::function _activateLink; std::function _activateMedia; + QVariant _clickHandlerContext; + std::shared_ptr _clickHandlerContextRef; MarkdownArticleSelection _selection; MarkdownArticleSelection _savedSelection; MarkdownArticleSelectionEndpoints _selectionEndpoints; diff --git a/Telegram/SourceFiles/tests/test_markdown_iv.cpp b/Telegram/SourceFiles/tests/test_markdown_iv.cpp index c647379e6b..03e356649a 100644 --- a/Telegram/SourceFiles/tests/test_markdown_iv.cpp +++ b/Telegram/SourceFiles/tests/test_markdown_iv.cpp @@ -1382,6 +1382,18 @@ void ForEachPreparedLink( }); } +[[nodiscard]] std::vector CollectPreparedBlocksByKind( + const std::vector &blocks, + PreparedBlockKind kind) { + auto result = std::vector(); + ForEachPreparedBlock(blocks, [&](const PreparedBlock &block) { + if (block.kind == kind) { + result.push_back(&block); + } + }); + return result; +} + struct InlineTextObjectMatch { EntityInText entity; InlineTextObjectEntity object; @@ -2263,6 +2275,96 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { } } +void CheckCodeBlockTrailingNewlineTrim(bool *ok) { + const auto markdownLabel = u"generated-code-block-trailing-newline"_q; + const auto parsed = ParseMarkdownForIv(QByteArray(R"(```cpp +alpha + +``` + + beta +)"), ParseOptions{ markdownLabel }); + Check( + parsed.ok, + markdownLabel + u" parse failed: "_q + parsed.error, + ok); + if (!parsed.ok) { + return; + } + auto renderer = std::make_shared(); + const auto prepared = PrepareParsedDocumentForTest( + parsed.document, + markdownLabel, + renderer); + Check( + !prepared.failure.failed(), + markdownLabel + u" prepare failed: "_q + + PrepareFailureReason(prepared.failure), + ok); + if (prepared.failure.failed()) { + return; + } + const auto markdownCodeBlocks = CollectPreparedBlocksByKind( + prepared.blocks.blocks, + PreparedBlockKind::CodeBlock); + Check( + markdownCodeBlocks.size() == 2, + markdownLabel + u" code block count"_q, + ok); + if (markdownCodeBlocks.size() == 2) { + Check( + markdownCodeBlocks[0]->text.text == u"alpha\n"_q, + markdownLabel + u" fenced block trims one newline"_q, + ok); + Check( + markdownCodeBlocks[1]->text.text == u"beta"_q, + markdownLabel + u" indented block trims one newline"_q, + ok); + } + + const auto nativeLabel = u"native-iv-preformatted-trailing-newline"_q; + auto nativeBlocks = QVector(); + nativeBlocks.push_back(MTP_pageBlockPreformatted( + NativeIvText(u"single\n"_q), + MTP_string("txt"))); + nativeBlocks.push_back(MTP_pageBlockPreformatted( + NativeIvText(u"double\n\n"_q), + MTP_string("txt"))); + auto nativeSource = NativeIvSource(std::move(nativeBlocks)); + const auto nativePrepared = TryPrepareNativeInstantView({ + .source = &nativeSource, + }); + Check( + nativePrepared.supported(), + nativeLabel + u" prepare supported"_q, + ok); + Check( + !nativePrepared.content.failure.failed(), + nativeLabel + u" prepare failed"_q, + ok); + if (!nativePrepared.supported() + || nativePrepared.content.failure.failed()) { + return; + } + const auto nativeCodeBlocks = CollectPreparedBlocksByKind( + nativePrepared.content.blocks.blocks, + PreparedBlockKind::CodeBlock); + Check( + nativeCodeBlocks.size() == 2, + nativeLabel + u" code block count"_q, + ok); + if (nativeCodeBlocks.size() == 2) { + Check( + nativeCodeBlocks[0]->text.text == u"single"_q, + nativeLabel + u" single trailing newline trimmed"_q, + ok); + Check( + nativeCodeBlocks[1]->text.text == u"double\n"_q, + nativeLabel + u" extra trailing newline preserved"_q, + ok); + } +} + void CheckNativeInstantViewArticleCoverage(bool *ok) { const auto placeholderLabel = u"native-iv-placeholder-article"_q; auto placeholderBlocks = QVector(); @@ -4208,6 +4310,7 @@ ThisIsALongUnbrokenStringToTestWrappingBehavior_ABCD1234EFGH5678IJKL CheckInlineTextObjectPrepareCoverage(&ok); CheckNativeInstantViewPrepareCoverage(&ok); + CheckCodeBlockTrailingNewlineTrim(&ok); CheckPrepareCoverage(markdownFixture, latexFixture, &ok); CheckPrepareLinkClassification(markdownFixture.path, &ok); CheckArticleRenderSmoke(markdownFixture, latexFixture, &ok);