diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 9be549470c..7498125508 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -1596,6 +1596,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_preview_loading" = "Getting Link Info..."; "lng_preview_cant" = "Could not generate preview for this link."; +"lng_markdown_preview_cant" = "Can't preview this Markdown file"; +"lng_markdown_preview_open_file" = "Open file"; "lng_profile_settings_section" = "Settings"; "lng_profile_bot_settings" = "Bot Settings"; diff --git a/Telegram/SourceFiles/iv/iv.style b/Telegram/SourceFiles/iv/iv.style index 5438447812..8d4bdb7bb9 100644 --- a/Telegram/SourceFiles/iv/iv.style +++ b/Telegram/SourceFiles/iv/iv.style @@ -165,3 +165,11 @@ ivMarkdownTableHeaderStyle: TextStyle(ivMarkdownParagraphStyle) { ivMarkdownTableMinColumnWidth: 96px; ivMarkdownTableOverflowWidth: 4px; ivMarkdownTableOverflowFg: windowSubTextFg; +ivMarkdownFailureLabel: FlatLabel(defaultFlatLabel) { + minWidth: 280px; + textFg: windowSubTextFg; + align: align(center); + style: ivMarkdownParagraphStyle; +} +ivMarkdownFailureWidth: 360px; +ivMarkdownFailureSkip: 12px; diff --git a/Telegram/SourceFiles/iv/markdown/README.md b/Telegram/SourceFiles/iv/markdown/README.md index 53851a3338..f147d807d3 100644 --- a/Telegram/SourceFiles/iv/markdown/README.md +++ b/Telegram/SourceFiles/iv/markdown/README.md @@ -1,12 +1,89 @@ # Native Markdown Instant View -The native Markdown Instant View skeleton is compiled only when Telegram Desktop is configured with `-D TDESKTOP_NATIVE_MARKDOWN_IV=ON`. +This directory contains the native Markdown Instant View proof of concept behind `TDESKTOP_NATIVE_MARKDOWN_IV`. -Configure and build the gated-on state from the repository root: +Current scope: + +- local `.md` / `.markdown` interception from the saved-document open flow +- UTF-8 validation plus explicit parser limits +- cmark-gfm parsing with tables, task lists, strikethrough, autolinks, tagfilter, and footnotes +- native prepare/layout/paint for paragraphs, lists, quotes, code blocks, tables, details blocks, and math +- MicroTeX-backed formula rendering with a preview-lifetime cache +- selection, copy, `Copy Link`, and `Open Link` actions inside the native preview + +## Build + +The feature is compiled only when `TDESKTOP_NATIVE_MARKDOWN_IV=ON`. + +For the app build, use a Debug tree: ```bat -cmake -S . -B out -D TDESKTOP_NATIVE_MARKDOWN_IV=ON cmake --build out --config Debug --target Telegram ``` -The gated state now builds a cmark-gfm parser adapter, a value document model, a deterministic debug dump, and math extraction metadata. It also intercepts already-local `.md` and `.markdown` files and opens a minimal native diagnostic window. Full native Markdown rendering and MicroTeX output are still future work. +The regression probe is emitted only when the build tree is also configured with `DESKTOP_APP_TEST_APPS=ON`. In that case: + +```bat +cmake --build out --config Debug --target test_markdown_iv +out\Debug\test_markdown_iv.exe +``` + +## Manual smoke test + +1. Build a Debug app with `TDESKTOP_NATIVE_MARKDOWN_IV=ON`. +2. Launch Telegram Desktop from that build. +3. Save or download a local Markdown file. +4. Click the local `.md` or `.markdown` document from chat history. + +Expected behavior: + +- supported local Markdown opens in the native preview +- validation, parse, or unsupported-document rejection falls back to the normal file open path +- terminal post-open prepare failures show `Can't preview this Markdown file` with an `Open file` action + +Useful manual checks: + +- drag selection across multiple blocks +- copy inline math, display math, code blocks, and tables +- verify `Copy Link` / `Open Link` on external, anchor, footnote, and local-Markdown links +- verify rejected relative links do not expose an open action + +## Regression target coverage + +`test_markdown_iv` now exercises both parser and prepare/render layers: + +- parses `markdown-example.md` and `latex-markdown-test.md` +- checks known inline/display formula counts +- keeps the currency, escaped-dollar, fenced-code, and inline-code exclusions +- asserts inline-formula `copySource`, display-math `formulaTex`, details/footnote preservation, and link classification +- verifies oversized-table flattening diagnostics +- runs a headless MicroTeX render/cache smoke pass twice through the same renderer and checks second-pass cache hits + +## Limits and failure policy + +Current hard limits: + +- source bytes: 4 MiB +- cmark nodes: 100000 +- nesting depth: 128 +- formula bytes: 64 KiB +- formula count: 10000 +- prepared blocks: 4096 +- rendered table rows / columns / cells: 128 / 16 / 1024 +- display-math logical render cap: 1600 x 1200 +- formula image cap: 128 MiB physical image budget +- formula cache budget: 32 MiB per preview renderer + +Policy: + +- oversized or invalid sources reject before native open and fall back to normal file open +- oversized tables flatten into fallback blocks with diagnostics instead of failing the whole preview +- formula overflow or render failure falls back per formula and keeps the preview alive +- terminal prepare failures, including the prepared-block budget, switch the preview surface to the user-facing failure state + +## Known gaps + +- preview entry is local-file-only; message-bubble embedding is still future work +- details-body reparsing still falls back to plain paragraph text when nested formulas or footnotes would be introduced +- table interaction supports per-cell text selection and whole-table copy, but not arbitrary rectangular multi-cell selection +- relative links are accepted only when they resolve to safe local Markdown targets under the source directory diff --git a/Telegram/SourceFiles/iv/markdown/REPORT.md b/Telegram/SourceFiles/iv/markdown/REPORT.md new file mode 100644 index 0000000000..b922c4222a --- /dev/null +++ b/Telegram/SourceFiles/iv/markdown/REPORT.md @@ -0,0 +1,87 @@ +# Native Markdown IV Report + +Status: production-direction PoC hardening completed with the expanded prepare/render test target kept in place. + +## Verification snapshot + +Local verification was run in Debug trees with `TDESKTOP_NATIVE_MARKDOWN_IV=ON`: + +- build: `out`, target `Telegram`, result pass +- executable: `out/Debug/test_markdown_iv.exe`, result pass +- throwaway `out_phase5/Debug/test_markdown_iv.exe`, result pass before cleanup +- MicroTeX backend: linked + +## Explicit limits + +- source bytes: 4 MiB +- cmark nodes: 100000 +- nesting depth: 128 +- formula bytes: 64 KiB +- formula count: 10000 +- prepared blocks: 4096 +- rendered table rows: 128 +- rendered table columns: 16 +- rendered table cells: 1024 +- display-math logical render cap: 1600 x 1200 +- formula physical image cap: 128 MiB +- formula cache budget: 32 MiB per preview renderer + +## Failure behavior + +- Pre-open validation or parse rejection returns control to the normal file-open path. +- Unsupported or empty Markdown documents also fall back before native preview open. +- Post-open terminal prepare failures, including the prepared-block budget, switch the preview surface to `Can't preview this Markdown file` and expose `Open file`. +- Oversized tables flatten into fallback blocks and increment prepare warnings instead of failing the preview. +- Formula overflow or render failure stays local to the formula slot and uses fallback text / overflow styling instead of aborting the document. + +## Selection and copy scope + +- drag selection works across multiple prepared segments in document order +- inline formulas copy their original `$...$` source through `copySource` +- display math copies as `$$...$$` +- code blocks copy the raw prepared block text, not visually wrapped lines +- tables support per-cell text selection and whole-table copy serialization +- context menus expose `Copy Text` / `Copy Selected Text`, `Copy Link`, and `Open Link` +- rejected relative links and details toggles intentionally do not expose open actions + +## Measured debug counters + +From the local `test_markdown_iv.exe` run: + +- `markdown-example.md`: `prepare_ms=18`, `formula_ms=14`, `prepare_warnings=0`, `formula_warnings=0`, `prepared_formulas=2` +- `latex-markdown-test.md`: `prepare_ms=342`, `formula_ms=340`, `prepare_warnings=0`, `formula_warnings=0`, `prepared_formulas=124` +- cache smoke, first pass: `hits=6`, `misses=120` +- cache smoke, second pass through the same renderer: `hits=126`, `misses=0` +- cache usage after second pass: `1651956` bytes + +The regression target also exercises a synthetic display-math render-cap failure by forcing `displayMathMaxRenderWidth = 1` and verifying that prepare completes with formula warnings and a nonterminal fallback result. It separately builds a parsed document that exceeds the prepared-block budget and verifies the real terminal `prepared-block-limit` failure path. + +## Regression target coverage + +`test_markdown_iv` now covers: + +- parser validation and both shipped fixtures +- known inline/display formula counts +- currency, escaped-dollar, fenced-code, and inline-code exclusions +- inline-formula `copySource` +- display-math `formulaTex` +- prepared table structure and oversize-table flatten diagnostics +- prepared-block terminal failure behavior +- details-block preservation +- footnote references, backlinks, and bottom-list preservation +- safe local-Markdown-link classification versus rejected relative links +- MicroTeX render/cache reuse across repeated prepare passes + +## Known unsupported or intentionally deferred cases + +- native preview entry is still limited to local files; message-bubble embedding is not implemented +- details-body reparsing degrades to plain paragraph text when nested formulas or footnotes would need a second prepared subdocument +- table selection is not a spreadsheet-style rectangular model; whole-table copy is the supported fallback +- formula cache reuse is scoped to a preview renderer lifetime, not shared globally across preview windows + +## Next steps for message-bubble embedding + +- split the local-file controller surface from the reusable document-view surface more explicitly +- define a bubble-friendly width policy and table overflow policy for chat layout +- add preview-root creation from message media data instead of the local-file resolver only +- decide whether preview renderers should share a broader formula cache across bubbles or windows diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp index 191602ffac..ff8f1d52b7 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp @@ -9,6 +9,7 @@ #include "styles/palette.h" #include "styles/style_window.h" +#include #include #include #include @@ -22,7 +23,6 @@ namespace Iv::Markdown { namespace { -constexpr auto kMaxSourceBytes = 4 * 1024 * 1024; constexpr auto kZoomStep = int(10); struct OpenTarget { @@ -279,12 +279,13 @@ void Controller::finishClose() { } // namespace bool TryOpenLocalFile(const QString &path) { + const auto &limits = ParseLimitsForIv(); const auto target = ParseOpenTarget(path); const auto info = QFileInfo(target.path); if (!IsReadableLocalFile(info)) { return false; } - if (info.size() > kMaxSourceBytes) { + if (info.size() > limits.maxSourceBytes) { DEBUG_LOG(("Native Markdown IV: rejected local file too large: %1" ).arg(target.path)); return false; @@ -294,40 +295,85 @@ bool TryOpenLocalFile(const QString &path) { if (!ReadLocalSource(target.path, &bytes)) { return false; } - if (bytes.size() > kMaxSourceBytes) { + if (bytes.size() > limits.maxSourceBytes) { DEBUG_LOG(("Native Markdown IV: rejected local file too large: %1" ).arg(target.path)); return false; } const auto fallbackTitle = info.fileName(); +#ifndef NDEBUG + auto validationTimer = QElapsedTimer(); + validationTimer.start(); +#endif auto validated = ValidateMarkdownSourceForIv( bytes, ParseOptions{ fallbackTitle }); +#ifndef NDEBUG + const auto validationMs = validationTimer.elapsed(); +#endif if (!validated.ok) { +#ifndef NDEBUG + DEBUG_LOG(("Native Markdown IV: source validation failure (%1, %2 ms): %3" + ).arg(validated.error + ).arg(validationMs + ).arg(target.path)); +#else DEBUG_LOG(("Native Markdown IV: source validation failure (%1): %2" ).arg(validated.error ).arg(target.path)); +#endif return false; } +#ifndef NDEBUG + auto parseTimer = QElapsedTimer(); + parseTimer.start(); +#endif auto result = ParseMarkdownForIv(std::move(validated.source)); +#ifndef NDEBUG + const auto parseMs = parseTimer.elapsed(); +#endif if (!result.ok) { const auto &error = result.error; if (error.startsWith(u"cmark-"_q)) { +#ifndef NDEBUG + DEBUG_LOG(("Native Markdown IV: cmark parse failure (%1, %2 ms): %3" + ).arg(error + ).arg(parseMs + ).arg(target.path)); +#else DEBUG_LOG(("Native Markdown IV: cmark parse failure (%1): %2" ).arg(error ).arg(target.path)); +#endif } else { +#ifndef NDEBUG + DEBUG_LOG(("Native Markdown IV: parse failure (%1, %2 ms): %3" + ).arg(error + ).arg(parseMs + ).arg(target.path)); +#else DEBUG_LOG(("Native Markdown IV: parse failure (%1): %2" ).arg(error ).arg(target.path)); +#endif } return false; } +#ifndef NDEBUG + auto previewEligibilityTimer = QElapsedTimer(); + previewEligibilityTimer.start(); +#endif if (!AcceptsPreview(result.document)) { +#ifndef NDEBUG + DEBUG_LOG(("Native Markdown IV: unsupported or empty document (%1 ms): %2" + ).arg(previewEligibilityTimer.elapsed() + ).arg(target.path)); +#else DEBUG_LOG(("Native Markdown IV: unsupported or empty document: %1" ).arg(target.path)); +#endif return false; } LogDocumentWarnings(result.document, target.path); @@ -340,8 +386,15 @@ bool TryOpenLocalFile(const QString &path) { title, info.absoluteFilePath(), target.fragment); +#ifndef NDEBUG + DEBUG_LOG(("Native Markdown IV: opened as native Markdown IV (%1 ms validate, %2 ms parse): %3" + ).arg(validationMs + ).arg(parseMs + ).arg(target.path)); +#else DEBUG_LOG(("Native Markdown IV: opened as native Markdown IV: %1" ).arg(target.path)); +#endif return true; } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_math_renderer.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_math_renderer.cpp index 9b0d6996c0..0cc909e982 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_math_renderer.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_math_renderer.cpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -57,19 +58,115 @@ constexpr auto kMaxFormulaImageBytes = int64(128) * 1024 * 1024; || (error == u"physical-image-cap-exceeded"_q); } -} // namespace - -const RenderedFormula *FormulaCache::find(const FormulaCacheKey &key) const { - const auto i = _entries.find(key); - return (i != _entries.end()) ? &i->second : nullptr; +[[nodiscard]] int64 EstimateQStringBytes(const QString &value) { + return int64(value.size()) * sizeof(QChar); } -void FormulaCache::put(FormulaCacheKey key, RenderedFormula value) { - _entries[std::move(key)] = std::move(value); +[[nodiscard]] int64 EstimateQImageBytes(const QImage &image) { + return image.isNull() ? 0 : int64(image.sizeInBytes()); +} + +} // namespace + +const RenderedFormula *FormulaCache::find(const FormulaCacheKey &key) { + const auto i = _entries.find(key); + if (i == _entries.end()) { + return nullptr; + } + touch(i); + return &i->second.value; +} + +FormulaCacheMutation FormulaCache::put( + FormulaCacheKey key, + RenderedFormula value) { + if (_budgetBytes <= 0) { + if (const auto i = _entries.find(key); i != _entries.end()) { + erase(i); + } + return {}; + } + const auto sizeBytes = estimateBytes(key, value); + if (sizeBytes > _budgetBytes) { + if (const auto i = _entries.find(key); i != _entries.end()) { + erase(i); + } + return {}; + } + if (const auto i = _entries.find(key); i != _entries.end()) { + erase(i); + } + _lru.push_back(key); + const auto lru = std::prev(_lru.end()); + _entries.emplace(std::move(key), Entry{ + .value = std::move(value), + .sizeBytes = sizeBytes, + .lru = lru, + }); + _sizeBytes += sizeBytes; + return evictToBudget(); +} + +FormulaCacheMutation FormulaCache::setBudgetBytes(int64 bytes) { + _budgetBytes = std::max(0, bytes); + return evictToBudget(); +} + +int64 FormulaCache::budgetBytes() const { + return _budgetBytes; +} + +int64 FormulaCache::sizeBytes() const { + return _sizeBytes; +} + +int FormulaCache::size() const { + return int(_entries.size()); } void FormulaCache::clear() { _entries.clear(); + _lru.clear(); + _sizeBytes = 0; +} + +int64 FormulaCache::estimateBytes( + const FormulaCacheKey &key, + const RenderedFormula &value) const { + return sizeof(FormulaCacheKey) + + sizeof(Entry) + + EstimateQStringBytes(key.trimmedTex) + + EstimateQImageBytes(value.image) + + EstimateQStringBytes(value.fallbackText) + + EstimateQStringBytes(value.error); +} + +void FormulaCache::touch(std::map::iterator i) { + _lru.erase(i->second.lru); + _lru.push_back(i->first); + i->second.lru = std::prev(_lru.end()); +} + +void FormulaCache::erase(std::map::iterator i) { + _sizeBytes -= i->second.sizeBytes; + _lru.erase(i->second.lru); + _entries.erase(i); +} + +FormulaCacheMutation FormulaCache::evictToBudget() { + auto result = FormulaCacheMutation(); + while (((_budgetBytes <= 0) || (_sizeBytes > _budgetBytes)) && !_lru.empty()) { + const auto oldest = _lru.front(); + const auto i = _entries.find(oldest); + if (i == _entries.end()) { + _lru.pop_front(); + continue; + } + result.evictedBytes += i->second.sizeBytes; + ++result.evictedEntries; + erase(i); + } + return result; } RenderedFormula MathRenderer::renderFormula( @@ -85,7 +182,7 @@ RenderedFormula MathRenderer::renderFormula( if (rejectRequestByCaps(key, &error)) { ++_debugCounters.failed; auto failure = makeFailure(key, error, TooLargeFailure(error)); - _cache.put(key, failure); + applyCacheMutation(_cache.put(key, failure)); return failure; } auto normalized = request; @@ -102,13 +199,13 @@ RenderedFormula MathRenderer::renderFormula( key, rendered.error, TooLargeFailure(rendered.error)); - _cache.put(key, failure); + applyCacheMutation(_cache.put(key, failure)); return failure; } if (rejectResultByCaps(key, rendered, &error)) { ++_debugCounters.failed; auto failure = makeFailure(key, error, TooLargeFailure(error)); - _cache.put(key, failure); + applyCacheMutation(_cache.put(key, failure)); return failure; } auto result = RenderedFormula(); @@ -116,7 +213,7 @@ RenderedFormula MathRenderer::renderFormula( result.logicalSize = rendered.logicalSize; result.success = true; ++_debugCounters.rendered; - _cache.put(key, result); + applyCacheMutation(_cache.put(key, result)); return result; } @@ -124,6 +221,8 @@ void MathRenderer::clearCache(bool resetDebugCounters) { _cache.clear(); if (resetDebugCounters) { _debugCounters = FormulaDebugCounters(); + } else { + syncCacheCounters(); } } @@ -133,12 +232,25 @@ void MathRenderer::invalidate(bool resetDebugCounters) { void MathRenderer::resetDebugCounters() { _debugCounters = FormulaDebugCounters(); + syncCacheCounters(); +} + +void MathRenderer::setCacheBudgetBytes(int64 bytes) { + applyCacheMutation(_cache.setBudgetBytes(bytes)); } const FormulaDebugCounters &MathRenderer::debugCounters() const { return _debugCounters; } +int64 MathRenderer::cacheBudgetBytes() const { + return _cache.budgetBytes(); +} + +int64 MathRenderer::cacheUsageBytes() const { + return _cache.sizeBytes(); +} + FormulaCacheKey MathRenderer::makeKey( const MicrotexRenderRequest &request, int paletteVersion) const { @@ -236,4 +348,15 @@ bool MathRenderer::rejectResultByCaps( return false; } +void MathRenderer::syncCacheCounters() { + _debugCounters.cacheEntries = _cache.size(); + _debugCounters.cacheBytes = _cache.sizeBytes(); +} + +void MathRenderer::applyCacheMutation(FormulaCacheMutation mutation) { + _debugCounters.evictedEntries += mutation.evictedEntries; + _debugCounters.evictedBytes += mutation.evictedBytes; + syncCacheCounters(); +} + } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_math_renderer.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_math_renderer.h index f407cc6dfb..e99d43aa72 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_math_renderer.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_math_renderer.h @@ -2,11 +2,14 @@ #include "iv/markdown/iv_markdown_microtex.h" +#include "base/basic_types.h" + #include #include #include #include +#include #include #include @@ -78,17 +81,48 @@ struct FormulaDebugCounters { int failed = 0; int hits = 0; int misses = 0; + int evictedEntries = 0; + int64 evictedBytes = 0; + int cacheEntries = 0; + int64 cacheBytes = 0; +}; + +struct FormulaCacheMutation { + int evictedEntries = 0; + int64 evictedBytes = 0; }; class FormulaCache { public: [[nodiscard]] const RenderedFormula *find( - const FormulaCacheKey &key) const; - void put(FormulaCacheKey key, RenderedFormula value); + const FormulaCacheKey &key); + [[nodiscard]] FormulaCacheMutation put( + FormulaCacheKey key, + RenderedFormula value); + [[nodiscard]] FormulaCacheMutation setBudgetBytes(int64 bytes); + [[nodiscard]] int64 budgetBytes() const; + [[nodiscard]] int64 sizeBytes() const; + [[nodiscard]] int size() const; void clear(); private: - std::map _entries; + struct Entry { + RenderedFormula value; + int64 sizeBytes = 0; + std::list::iterator lru; + }; + + [[nodiscard]] int64 estimateBytes( + const FormulaCacheKey &key, + const RenderedFormula &value) const; + void touch(std::map::iterator i); + void erase(std::map::iterator i); + [[nodiscard]] FormulaCacheMutation evictToBudget(); + + std::map _entries; + std::list _lru; + int64 _budgetBytes = 32 * 1024 * 1024; + int64 _sizeBytes = 0; }; @@ -100,8 +134,11 @@ public: void clearCache(bool resetDebugCounters = false); void invalidate(bool resetDebugCounters = false); void resetDebugCounters(); + void setCacheBudgetBytes(int64 bytes); [[nodiscard]] const FormulaDebugCounters &debugCounters() const; + [[nodiscard]] int64 cacheBudgetBytes() const; + [[nodiscard]] int64 cacheUsageBytes() const; private: [[nodiscard]] FormulaCacheKey makeKey( @@ -118,6 +155,8 @@ private: const FormulaCacheKey &key, const MicrotexRenderResult &result, QString *error) const; + void syncCacheCounters(); + void applyCacheMutation(FormulaCacheMutation mutation); FormulaCache _cache; FormulaDebugCounters _debugCounters; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.cpp index 7e9deabfea..b1e2978afa 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.cpp @@ -21,13 +21,19 @@ #include namespace Iv::Markdown { -namespace { -constexpr auto kMaxSourceBytes = 4 * 1024 * 1024; -constexpr auto kMaxCmarkNodes = 100000; -constexpr auto kMaxNesting = 128; -constexpr auto kMaxFormulaBytes = 64 * 1024; -constexpr auto kMaxFormulaCount = 10000; +const MarkdownParseLimits &ParseLimitsForIv() { + static const auto result = MarkdownParseLimits{ + .maxSourceBytes = 4 * 1024 * 1024, + .maxCmarkNodes = 100000, + .maxNesting = 128, + .maxFormulaBytes = 64 * 1024, + .maxFormulaCount = 10000, + }; + return result; +} + +namespace { struct ParserDeleter { void operator()(cmark_parser *parser) const; @@ -589,18 +595,19 @@ void RecordCapabilities(cmark_node *node, ParserState *state) { cmark_node *node, ParserState *state, int depth) { + const auto &limits = ParseLimitsForIv(); if (!node || !state || state->failed) { return false; } if (state->stats) { state->stats->maxDepth = std::max(state->stats->maxDepth, depth); } - if (depth > kMaxNesting) { + if (depth > limits.maxNesting) { return FailScanMetadata(state, "cmark-nesting-too-deep"); } if (state->stats) { ++state->stats->cmarkNodeCount; - if (state->stats->cmarkNodeCount > kMaxCmarkNodes) { + if (state->stats->cmarkNodeCount > limits.maxCmarkNodes) { return FailScanMetadata(state, "too-many-cmark-nodes"); } } @@ -976,10 +983,11 @@ void FillNodeAttributes( ParserState *state, int depth, MarkdownNode *out) { + const auto &limits = ParseLimitsForIv(); if (!node || !state || !out || state->failed) { return false; } - if (depth > kMaxNesting) { + if (depth > limits.maxNesting) { return FailScanMetadata(state, "cmark-nesting-too-deep"); } out->kind = NodeKindFor(node); @@ -1651,7 +1659,8 @@ void FillFormulaStats(PreparedDocument *document) { MarkdownSourceValidationResult ValidateMarkdownSourceForIv( const QByteArray &source, ParseOptions options) { - if (source.size() > kMaxSourceBytes) { + const auto &limits = ParseLimitsForIv(); + if (source.size() > limits.maxSourceBytes) { return ValidationFailure( std::move(options.sourceName), FromLatin1("source-too-large")); @@ -1683,6 +1692,7 @@ MarkdownSourceValidationResult ValidateMarkdownSourceForIv( } ParseResult ParseMarkdownForIv(ValidatedMarkdownSource source) { + const auto &limits = ParseLimitsForIv(); auto mask = std::vector(source.normalized.size(), false); const auto parserOptions = CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS @@ -1727,8 +1737,8 @@ ParseResult ParseMarkdownForIv(ValidatedMarkdownSource source) { mask, source.lineStarts, scanBlocks, - kMaxFormulaBytes, - kMaxFormulaCount, + limits.maxFormulaBytes, + limits.maxFormulaCount, &document.formulas, &error)) { return Failure(std::move(document.sourceName), std::move(error)); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.h index 196e598600..9e7df839c2 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_parse.h @@ -6,6 +6,14 @@ namespace Iv::Markdown { +struct MarkdownParseLimits { + int maxSourceBytes = 0; + int maxCmarkNodes = 0; + int maxNesting = 0; + int maxFormulaBytes = 0; + int maxFormulaCount = 0; +}; + struct ValidatedMarkdownSource { QByteArray normalized; QString decoded; @@ -19,6 +27,7 @@ struct MarkdownSourceValidationResult { bool ok = true; }; +[[nodiscard]] const MarkdownParseLimits &ParseLimitsForIv(); [[nodiscard]] MarkdownSourceValidationResult ValidateMarkdownSourceForIv( const QByteArray &source, ParseOptions options = {}); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp index 380a5cc404..9a3770569c 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -18,13 +19,27 @@ #include "styles/style_iv.h" namespace Iv::Markdown { + +const MarkdownPrepareLimits &PrepareLimitsForIv() { + static const auto result = MarkdownPrepareLimits{ + .tableRender = { + .maxRows = 128, + .maxColumns = 16, + .maxCells = 1024, + }, + .maxPreparedBlocks = 4096, + }; + return result; +} + +const MarkdownPrepareTableRenderLimits &PrepareTableRenderLimitsForIv() { + return PrepareLimitsForIv().tableRender; +} + namespace { constexpr auto kMaxVisualListDepth = 6; constexpr auto kMaxVisualQuoteDepth = 3; -constexpr auto kMaxRenderedTableRows = 128; -constexpr auto kMaxRenderedTableColumns = 16; -constexpr auto kMaxRenderedTableCells = 1024; struct PrepareContext { int listDepth = 0; @@ -44,7 +59,7 @@ struct PrepareState { int nextGeneratedId = 0; [[nodiscard]] bool cancelled() { - if (!request->cancelled) { + if (!request || !request->cancelled) { return false; } else if (!request->cancelled->load(std::memory_order_relaxed)) { return false; @@ -85,6 +100,32 @@ struct PrepareState { result.style.displayMathMaxRenderHeight); } + void addPrepareWarning() { + ++result.debug.prepareWarningCount; + } + + void addFormulaWarning() { + ++result.debug.formulaWarningCount; + } + + void addPrepareWarnings(int count) { + result.debug.prepareWarningCount += count; + } + + void addFormulaWarnings(int count) { + result.debug.formulaWarningCount += count; + } + + void setTerminalFailure( + PrepareTerminalFailure terminal, + QString debugReason) { + if (result.failure.failed()) { + return; + } + result.failure.terminal = terminal; + result.failure.debugReason = std::move(debugReason); + } + [[nodiscard]] QString formulaSourceText(int index) const { if (!request || !request->document @@ -154,6 +195,14 @@ enum class RawInlineTag { MarkClose, }; +[[nodiscard]] QString InvalidStyleReason( + const MarkdownStyleSnapshot &style) { + if (style.devicePixelRatio <= 0) { + return u"invalid-device-pixel-ratio"_q; + } + return QString(); +} + void ClearPreparedOutput(PreparedResult *result) { result->blocks.blocks.clear(); result->formulas.clear(); @@ -257,6 +306,7 @@ void ClearPreparedOutput(PreparedResult *result) { const PrepareState *state) { auto result = PreparedLink(); result.index = index; + result.copyText = target; if (target.startsWith(QChar('#'))) { result.kind = PreparedLinkKind::Anchor; result.target = NormalizeFragmentId(target.mid(1)); @@ -860,6 +910,7 @@ void AppendInline( .kind = preparedLink.kind, .target = preparedLink.target, .fragment = preparedLink.fragment, + .copyText = preparedLink.copyText, }); } break; case NodeKind::FootnoteReference: { @@ -894,6 +945,7 @@ void AppendInline( .index = uint16(index), .kind = PreparedLinkKind::Footnote, .target = FootnoteDefinitionAnchor(node), + .copyText = u"#"_q + FootnoteDefinitionAnchor(node), }); if (inlineFormulas) { const auto remembered = state->rememberFootnoteReferenceAnchor( @@ -1058,36 +1110,65 @@ void PrepareTableCellText( [[nodiscard]] bool ShouldFlattenTable( const MarkdownNode &node, - PrepareContext context) { + PrepareContext context, + PrepareState *state) { + const auto &limits = PrepareLimitsForIv().tableRender; if (context.listDepth > 0 || context.quoteDepth > 0) { + if (state) { + state->addPrepareWarning(); + } return true; } if (node.children.empty()) { + if (state) { + state->addPrepareWarning(); + } return true; } const auto rowCount = int(node.children.size()); - if (rowCount > kMaxRenderedTableRows) { + if (rowCount > limits.maxRows) { + if (state) { + state->addPrepareWarning(); + } return true; } auto cellCount = 0; for (const auto &row : node.children) { if (row.kind != NodeKind::TableRow || row.children.empty()) { + if (state) { + state->addPrepareWarning(); + } return true; } const auto width = EffectiveTableRowWidth(row); - if (!width || width > kMaxRenderedTableColumns) { + if (!width || width > limits.maxColumns) { + if (state) { + state->addPrepareWarning(); + } return true; } cellCount += width; - if (cellCount > kMaxRenderedTableCells) { + if (cellCount > limits.maxCells) { + if (state) { + state->addPrepareWarning(); + } return true; } } const auto columnCount = EffectiveTableColumnCount(node); - if (!columnCount || columnCount > kMaxRenderedTableColumns) { + if (!columnCount || columnCount > limits.maxColumns) { + if (state) { + state->addPrepareWarning(); + } return true; } - return (rowCount * columnCount) > kMaxRenderedTableCells; + if ((rowCount * columnCount) > limits.maxCells) { + if (state) { + state->addPrepareWarning(); + } + return true; + } + return false; } [[nodiscard]] std::vector PrepareFallbackBlocks( @@ -1098,9 +1179,9 @@ void PrepareTableCellText( [[nodiscard]] std::vector PrepareTableBlocks( const MarkdownNode &node, PrepareContext context, - PrepareState *state) { + PrepareState *state) { const auto columnCount = EffectiveTableColumnCount(node); - if (ShouldFlattenTable(node, context) || !columnCount) { + if (ShouldFlattenTable(node, context, state) || !columnCount) { return PrepareFallbackBlocks(node, context, state); } @@ -1286,6 +1367,7 @@ void AppendFootnoteBacklink(PreparedBlock *block, const QString &target) { .index = uint16(index), .kind = PreparedLinkKind::FootnoteBacklink, .target = target, + .copyText = u"#"_q + target, }); SortEntities(&block->text); } @@ -1337,6 +1419,20 @@ void AppendFootnotes( [[nodiscard]] std::vector PrepareNestedDetailsBody( const MarkdownNode &node, PrepareState *state) { + const auto fallback = [&] { + if (state) { + state->addPrepareWarning(); + } + auto blocks = std::vector(); + AppendRichBlock( + &blocks, + PreparedBlockKind::Paragraph, + 0, + TextWithEntities::Simple(node.detailsBody), + std::vector(), + std::vector()); + return blocks; + }; if (node.detailsBody.isEmpty()) { return {}; } @@ -1346,24 +1442,23 @@ void AppendFootnotes( if (!parsed.ok || !parsed.document.formulas.empty() || parsed.document.stats.footnotesSeen) { - auto fallback = std::vector(); - AppendRichBlock( - &fallback, - PreparedBlockKind::Paragraph, - 0, - TextWithEntities::Simple(node.detailsBody), - std::vector(), - std::vector()); - return fallback; + return fallback(); } auto nestedRequest = PrepareRequest{ .document = std::make_shared(parsed.document), + .renderer = state->request->renderer, .style = state->result.style, + .generation = state->request->generation, .sourcePath = state->request->sourcePath, + .cancelled = state->request->cancelled, }; auto nested = PrepareSynchronously(std::move(nestedRequest)); + state->addPrepareWarnings(nested.debug.prepareWarningCount); + state->addFormulaWarnings(nested.debug.formulaWarningCount); return nested.cancelled ? std::vector() + : nested.failure.failed() + ? fallback() : std::move(nested.blocks.blocks); } @@ -1667,6 +1762,15 @@ void AppendFootnotes( return result; } +[[nodiscard]] int CountPreparedBlocks(const std::vector &blocks) { + auto result = 0; + for (const auto &block : blocks) { + ++result; + result += CountPreparedBlocks(block.children); + } + return result; +} + [[nodiscard]] int FormulaSlotCount(const PreparedDocument &document) { auto result = 0; for (const auto &formula : document.formulas) { @@ -1681,7 +1785,14 @@ void AppendFootnotes( [[nodiscard]] bool RenderPreparedFormulas(PrepareState *state) { const auto &style = state->result.style; - auto renderer = MathRenderer(); + auto ownedRenderer = std::shared_ptr(); + auto renderer = state->request ? state->request->renderer.get() : nullptr; + if (!renderer) { + ownedRenderer = std::make_shared(); + renderer = ownedRenderer.get(); + } + auto timer = QElapsedTimer(); + timer.start(); for (auto &slot : state->result.formulas) { if (!slot.present) { continue; @@ -1689,7 +1800,7 @@ void AppendFootnotes( if (state->cancelled()) { return false; } - slot.rendered = renderer.renderFormula({ + slot.rendered = renderer->renderFormula({ .trimmedTex = slot.trimmedTex, .kind = slot.kind, .textSize = slot.textSize @@ -1704,10 +1815,15 @@ void AppendFootnotes( .foreground = style.displayMathForegroundColor, .devicePixelRatio = style.devicePixelRatio, }, style.paletteVersion); + if (!slot.rendered.success) { + state->addFormulaWarning(); + } if (state->cancelled()) { + state->result.debug.formulaRenderMs = int(timer.elapsed()); return false; } } + state->result.debug.formulaRenderMs = int(timer.elapsed()); return true; } @@ -1797,29 +1913,58 @@ MarkdownStyleSnapshot CaptureMarkdownStyleSnapshot() { PreparedResult PrepareSynchronously(PrepareRequest request) { auto state = PrepareState(); + auto timer = QElapsedTimer(); + timer.start(); state.request = &request; state.result.style = request.style; state.result.generation = request.generation; + const auto finish = [&] { + state.result.debug.prepareMs = int(timer.elapsed()); + return std::move(state.result); + }; if (!request.document) { - return state.result; + state.setTerminalFailure( + PrepareTerminalFailure::InvalidRequest, + u"missing-document"_q); + return finish(); + } + if (const auto invalidStyle = InvalidStyleReason(request.style); + !invalidStyle.isEmpty()) { + state.setTerminalFailure( + PrepareTerminalFailure::InvalidStyle, + invalidStyle); + return finish(); } state.sourceUtf8 = request.document->sourceText.toUtf8(); state.result.formulas.resize(FormulaSlotCount(*request.document)); + state.result.debug.sourceWarningCount = int(request.document->warnings.size()); if (state.cancelled()) { - return std::move(state.result); + return finish(); } state.result.blocks = PrepareRenderData(*request.document, &state); if (state.result.cancelled) { ClearPreparedOutput(&state.result); - return std::move(state.result); + return finish(); + } + if (CountPreparedBlocks(state.result.blocks.blocks) + > PrepareLimitsForIv().maxPreparedBlocks) { + state.setTerminalFailure( + PrepareTerminalFailure::DocumentTooLarge, + u"prepared-block-limit"_q); + ClearPreparedOutput(&state.result); + return finish(); } if (!RenderPreparedFormulas(&state)) { ClearPreparedOutput(&state.result); + return finish(); } - return std::move(state.result); + if (state.result.failure.failed()) { + ClearPreparedOutput(&state.result); + } + return finish(); } void PrepareAsync(PrepareRequest request, Fn done) { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h index 8f3dc74776..cd1eb13a2a 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h @@ -43,6 +43,7 @@ struct PreparedLink { PreparedLinkKind kind = PreparedLinkKind::External; QString target; QString fragment; + QString copyText; }; struct PreparedInlineObject { @@ -179,6 +180,42 @@ struct MarkdownStyleSnapshot { int devicePixelRatio = 1; }; +struct MarkdownPrepareTableRenderLimits { + int maxRows = 0; + int maxColumns = 0; + int maxCells = 0; +}; + +struct MarkdownPrepareLimits { + MarkdownPrepareTableRenderLimits tableRender; + int maxPreparedBlocks = 0; +}; + +enum class PrepareTerminalFailure { + None, + InvalidRequest, + InvalidStyle, + DocumentTooLarge, + InternalError, +}; + +struct PrepareFailureStatus { + PrepareTerminalFailure terminal = PrepareTerminalFailure::None; + QString debugReason; + + [[nodiscard]] bool failed() const { + return (terminal != PrepareTerminalFailure::None); + } +}; + +struct PrepareDebugStats { + int prepareMs = 0; + int formulaRenderMs = 0; + int sourceWarningCount = 0; + int prepareWarningCount = 0; + int formulaWarningCount = 0; +}; + struct PreparedFormulaSlot { QString trimmedTex; MathKind kind = MathKind::Display; @@ -191,6 +228,7 @@ struct PreparedFormulaSlot { struct PrepareRequest { std::shared_ptr document; + std::shared_ptr renderer; MarkdownStyleSnapshot style; PrepareGeneration generation = 0; QString sourcePath; @@ -201,10 +239,14 @@ struct PreparedResult { PreparedRenderDocument blocks; MarkdownStyleSnapshot style; std::vector formulas; + PrepareFailureStatus failure; + PrepareDebugStats debug; PrepareGeneration generation = 0; bool cancelled = false; }; +[[nodiscard]] const MarkdownPrepareTableRenderLimits &PrepareTableRenderLimitsForIv(); +[[nodiscard]] const MarkdownPrepareLimits &PrepareLimitsForIv(); [[nodiscard]] MarkdownStyleSnapshot CaptureMarkdownStyleSnapshot(); [[nodiscard]] PreparedResult PrepareSynchronously(PrepareRequest request); void PrepareAsync(PrepareRequest request, Fn done); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp index f75f1665f0..96ec5b55cc 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp @@ -3,23 +3,36 @@ #include "iv/markdown/iv_markdown_prepare.h" #include "iv/iv_delegate.h" +#include + #include +#include #include "base/weak_ptr.h" #include "core/credits_amount.h" #include "core/click_handler_types.h" +#include "core/file_utilities.h" #include "lang/lang_keys.h" #include "logs.h" #include "ui/click_handler.h" +#include "ui/integration.h" #include "ui/painter.h" #include "ui/rp_widget.h" #include "ui/style/style_core.h" #include "ui/text/text.h" +#include "ui/widgets/popup_menu.h" #include "ui/widgets/scroll_area.h" +#include "ui/widgets/buttons.h" #include "ui/widgets/labels.h" +#include +#include +#include +#include +#include #include #include +#include #include #include @@ -34,6 +47,7 @@ #include "styles/style_boxes.h" #include "styles/style_iv.h" #include "styles/style_layers.h" +#include "styles/style_menu_icons.h" namespace Iv::Markdown { namespace { @@ -86,6 +100,8 @@ struct LaidOutTableCell { QRect textRect; int textWidth = 0; style::align align = style::al_left; + int segmentIndex = -1; + int tableSegmentIndex = -1; }; struct LaidOutTableRow { @@ -100,6 +116,7 @@ struct LaidOutBlock { Ui::Text::String marker; Ui::Text::String language; Ui::Text::String fallbackLeaf; + QString copyText; std::vector children; std::vector tableRows; std::vector tableColumnWidths; @@ -125,8 +142,103 @@ struct LaidOutBlock { style::align formulaAlign = style::al_left; bool collapsed = false; bool overflowed = false; + int segmentIndex = -1; }; +enum class SelectableSegmentKind { + TextLeaf, + CodeBlock, + DisplayMath, + Table, +}; + +struct SelectableSegment { + SelectableSegmentKind kind = SelectableSegmentKind::TextLeaf; + const Ui::Text::String *leaf = nullptr; + const LaidOutBlock *block = nullptr; + const LaidOutTableCell *cell = nullptr; + QRect outerRect; + QRect textRect; + int textWidth = 0; + style::align align = style::al_left; + int index = -1; + int length = 0; + int tableSegmentIndex = -1; + + [[nodiscard]] bool isTextLeaf() const { + return (leaf != nullptr); + } +}; + +struct DocumentHitTestResult { + int segmentIndex = -1; + Ui::Text::StateResult state; + int forcedOffset = -1; + bool direct = false; + + [[nodiscard]] bool valid() const { + return (segmentIndex >= 0); + } +}; + +struct DocumentSelectionPosition { + int segment = -1; + int offset = 0; + + [[nodiscard]] bool valid() const { + return (segment >= 0); + } +}; + +inline bool operator==( + DocumentSelectionPosition a, + DocumentSelectionPosition b) { + return (a.segment == b.segment) && (a.offset == b.offset); +} + +inline bool operator!=( + DocumentSelectionPosition a, + DocumentSelectionPosition b) { + return !(a == b); +} + +struct DocumentSelection { + DocumentSelectionPosition from; + DocumentSelectionPosition to; + + [[nodiscard]] bool empty() const { + return !from.valid() + || !to.valid() + || (from == to); + } +}; + +struct SelectionEndpoint { + int segment = -1; + bool direct = false; + + [[nodiscard]] bool valid() const { + return (segment >= 0); + } +}; + +struct SelectionEndpoints { + SelectionEndpoint from; + SelectionEndpoint to; +}; + +inline bool operator==( + DocumentSelection a, + DocumentSelection b) { + return (a.from == b.from) && (a.to == b.to); +} + +inline bool operator!=( + DocumentSelection a, + DocumentSelection b) { + return !(a == b); +} + struct LayoutContext { int listDepth = 0; int quoteDepth = 0; @@ -161,6 +273,13 @@ constexpr auto kCodeTrailingGuard = 0x2060; return result; } +[[nodiscard]] int LeafTextLength(const Ui::Text::String &leaf) { + return std::clamp( + leaf.toString().size(), + 0, + int(std::numeric_limits::max())); +} + [[nodiscard]] int BlockSkip( const PreparedBlock &block, const MarkdownStyleSnapshot &style) { @@ -259,6 +378,108 @@ constexpr auto kCodeTrailingGuard = 0x2060; return result; } +[[nodiscard]] TextForMimeData CopyTextForDisplayMath(const LaidOutBlock &block) { + return TextForMimeData::Simple(u"$$"_q + block.copyText + u"$$"_q); +} + +[[nodiscard]] TextForMimeData CopyTextForCodeBlock( + const LaidOutBlock &block, + TextSelection selection = AllTextSelection) { + if (selection == AllTextSelection) { + auto rich = TextWithEntities::Simple(block.copyText); + if (!rich.text.isEmpty()) { + rich.entities.push_back(EntityInText( + EntityType::Code, + 0, + rich.text.size())); + } + return TextForMimeData::Rich(std::move(rich)); + } + auto from = 0; + auto to = 0; + auto displayPosition = 0; + auto column = 0; + auto found = false; + const auto &text = block.copyText; + for (auto i = 0, count = text.size(); i != count; ++i) { + const auto ch = text[i]; + const auto width = (ch == QChar::Tabulation) + ? (kCodeTabColumns - (column % kCodeTabColumns)) + : 1; + const auto nextDisplayPosition = displayPosition + width; + if (selection.to <= displayPosition) { + break; + } + if (selection.from < nextDisplayPosition + && selection.to > displayPosition) { + if (!found) { + from = i; + found = true; + } + to = i + 1; + } + displayPosition = nextDisplayPosition; + if (Ui::Text::IsNewline(ch)) { + column = 0; + } else { + column += width; + } + } + if (!found || to <= from) { + return TextForMimeData(); + } + auto rich = TextWithEntities::Simple(text.mid(from, to - from)); + if (!rich.text.isEmpty()) { + rich.entities.push_back(EntityInText( + EntityType::Code, + 0, + rich.text.size())); + } + return TextForMimeData::Rich(std::move(rich)); +} + +[[nodiscard]] TextForMimeData CopyTextForTable(const LaidOutBlock &block) { + auto result = TextForMimeData(); + auto firstRow = true; + for (const auto &row : block.tableRows) { + if (!firstRow) { + result.append(u"\n"_q); + } + firstRow = false; + auto firstCell = true; + for (const auto &cell : row.cells) { + if (!firstCell) { + result.append(u"\t"_q); + } + firstCell = false; + result.append(cell.leaf.toTextForMimeData()); + } + } + return result; +} + +[[nodiscard]] QString CopyableLinkText(const PreparedLink &link) { + if (!link.copyText.isEmpty()) { + return link.copyText; + } + switch (link.kind) { + case PreparedLinkKind::Anchor: + case PreparedLinkKind::Footnote: + case PreparedLinkKind::FootnoteBacklink: + return link.target.isEmpty() ? QString() : (u"#"_q + link.target); + case PreparedLinkKind::LocalFile: + return link.fragment.isEmpty() + ? link.target + : (link.target + u"#"_q + link.fragment); + case PreparedLinkKind::External: + return link.target; + case PreparedLinkKind::RejectedRelative: + case PreparedLinkKind::ToggleDetails: + return QString(); + } + return QString(); +} + class PreparedLinkClickHandler final : public ClickHandler { public: explicit PreparedLinkClickHandler(PreparedLink link) @@ -276,6 +497,27 @@ public: return _link.target; } + QString copyToClipboardText() const override { + return CopyableLinkText(_link); + } + + QString copyToClipboardContextItemText() const override { + switch (_link.kind) { + case PreparedLinkKind::RejectedRelative: + case PreparedLinkKind::ToggleDetails: + return QString(); + case PreparedLinkKind::External: + case PreparedLinkKind::Anchor: + case PreparedLinkKind::Footnote: + case PreparedLinkKind::FootnoteBacklink: + case PreparedLinkKind::LocalFile: + return copyToClipboardText().isEmpty() + ? QString() + : tr::lng_context_copy_link(tr::now); + } + return QString(); + } + private: PreparedLink _link; @@ -563,6 +805,7 @@ void SetTextLeaf( int width) { auto block = LaidOutBlock(); block.kind = PreparedBlockKind::CodeBlock; + block.copyText = prepared.text.text; const auto &padding = style.codePadding; block.textWidth = std::max(width - padding.left() - padding.right(), 1); @@ -630,6 +873,7 @@ void SetTextLeaf( auto block = LaidOutBlock(); block.kind = PreparedBlockKind::DisplayMath; block.formulaIndex = prepared.formulaIndex; + block.copyText = prepared.formulaTex; const auto &padding = style.displayMathPadding; const auto contentLeft = left + padding.left(); @@ -1180,6 +1424,101 @@ void SetTextLeaf( return LayoutFlowBlock(prepared, formulas, style, left, top, width); } +[[nodiscard]] int AddSelectableSegment( + std::vector *segments, + SelectableSegment segment) { + segment.index = int(segments->size()); + segment.length = std::max(segment.length, 0); + segments->push_back(std::move(segment)); + return segment.index; +} + +void CollectSelectableSegments( + std::vector *blocks, + std::vector *segments) { + if (!blocks) { + return; + } + for (auto &block : *blocks) { + block.segmentIndex = -1; + switch (block.kind) { + case PreparedBlockKind::Paragraph: + case PreparedBlockKind::Heading: + case PreparedBlockKind::Details: { + auto segment = SelectableSegment(); + segment.kind = SelectableSegmentKind::TextLeaf; + segment.leaf = &block.leaf; + segment.block = █ + segment.outerRect = block.textRect; + segment.textRect = block.textRect; + segment.textWidth = block.textWidth; + segment.length = LeafTextLength(block.leaf); + block.segmentIndex = AddSelectableSegment( + segments, + std::move(segment)); + } break; + case PreparedBlockKind::CodeBlock: { + auto segment = SelectableSegment(); + segment.kind = SelectableSegmentKind::CodeBlock; + segment.leaf = &block.leaf; + segment.block = █ + segment.outerRect = block.outer; + segment.textRect = block.textRect; + segment.textWidth = block.textWidth; + segment.length = LeafTextLength(block.leaf); + block.segmentIndex = AddSelectableSegment( + segments, + std::move(segment)); + } break; + case PreparedBlockKind::DisplayMath: { + auto segment = SelectableSegment(); + segment.kind = SelectableSegmentKind::DisplayMath; + segment.block = █ + segment.outerRect = block.visibleFormulaRect; + segment.length = 1; + block.segmentIndex = AddSelectableSegment( + segments, + std::move(segment)); + } break; + case PreparedBlockKind::Table: { + auto segment = SelectableSegment(); + segment.kind = SelectableSegmentKind::Table; + segment.block = █ + segment.outerRect = block.visibleTableRect; + segment.length = 1; + block.segmentIndex = AddSelectableSegment( + segments, + std::move(segment)); + for (auto &row : block.tableRows) { + for (auto &cell : row.cells) { + auto cellSegment = SelectableSegment(); + cellSegment.kind = SelectableSegmentKind::TextLeaf; + cellSegment.leaf = &cell.leaf; + cellSegment.block = █ + cellSegment.cell = &cell; + cellSegment.outerRect = cell.outer; + cellSegment.textRect = cell.textRect; + cellSegment.textWidth = cell.textWidth; + cellSegment.align = cell.align; + cellSegment.length = LeafTextLength(cell.leaf); + cellSegment.tableSegmentIndex = block.segmentIndex; + cell.tableSegmentIndex = block.segmentIndex; + cell.segmentIndex = AddSelectableSegment( + segments, + std::move(cellSegment)); + } + } + } break; + case PreparedBlockKind::List: + case PreparedBlockKind::ListItem: + case PreparedBlockKind::Quote: + case PreparedBlockKind::Rule: + break; + } + CollectSelectableSegments(&block.children, segments); + } +} + class DocumentLayout final { public: void invalidate(); @@ -1188,12 +1527,18 @@ public: [[nodiscard]] int height() const; [[nodiscard]] int anchorTop(const QString &anchorId) const; [[nodiscard]] const std::vector &blocks() const; + [[nodiscard]] const std::vector &segments() const; + [[nodiscard]] const SelectableSegment *segment(int index) const; + [[nodiscard]] DocumentHitTestResult hitTest( + QPoint point, + Ui::Text::StateRequest::Flags flags) const; private: int _width = -1; int _height = 0; std::vector _blocks; std::vector> _anchors; + std::vector _segments; }; @@ -1209,23 +1554,62 @@ public: void setZoom(int value); [[nodiscard]] int anchorTop(const QString &anchorId) const; [[nodiscard]] bool toggleDetails(const QString &anchorId); + [[nodiscard]] int lastRelayoutMs() const; int resizeGetHeight(int newWidth) override; protected: void paintEvent(QPaintEvent *e) override; + void keyPressEvent(QKeyEvent *e) override; + void contextMenuEvent(QContextMenuEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; + void mouseDoubleClickEvent(QMouseEvent *e) override; + void focusOutEvent(QFocusEvent *e) override; + void focusInEvent(QFocusEvent *e) override; void leaveEventHook(QEvent *e) override; void clickHandlerActiveChanged(const ClickHandlerPtr &, bool) override; void clickHandlerPressedChanged(const ClickHandlerPtr &, bool) override; private: + enum DragAction { + NoDrag = 0x00, + PrepareDrag = 0x01, + Dragging = 0x02, + Selecting = 0x04, + }; + [[nodiscard]] ClickHandlerPtr linkAt(QPoint point) const; + [[nodiscard]] DocumentHitTestResult hitTest( + QPoint point, + Ui::Text::StateRequest::Flags flags) const; + [[nodiscard]] DocumentSelection selectionForCopy() const; + [[nodiscard]] SelectionEndpoints selectionEndpointsForCopy() const; + [[nodiscard]] bool selectionContains( + DocumentSelection selection, + const DocumentHitTestResult &result) const; + [[nodiscard]] TextForMimeData textForSegment( + const SelectableSegment &segment, + TextSelection selection = AllTextSelection) const; + [[nodiscard]] TextForMimeData textForContext( + const DocumentHitTestResult &result) const; + [[nodiscard]] int selectionOffsetFromHit( + const DocumentHitTestResult &result) const; + [[nodiscard]] DocumentSelection selectionFromHit( + const DocumentHitTestResult &result) const; + [[nodiscard]] TextForMimeData getSelectedText() const; + void copySelectedText(); void relayoutCurrentWidth(); void forceRelayoutCurrentWidth(); - void updateHover(QPoint point); + void updateHover(const DocumentHitTestResult &state); + void resetSelection(); + void clearSelection(); + void dragActionStart(QPoint point, Qt::MouseButton button); + DocumentHitTestResult dragActionUpdate(QPoint point); + DocumentHitTestResult dragActionFinish( + QPoint point, + Qt::MouseButton button); void applyCursor(style::cursor cursor); [[nodiscard]] double zoomScale() const; @@ -1233,18 +1617,301 @@ private: DocumentLayout _layout; std::optional _textPalette; std::function _activateLink; + DocumentSelection _selection; + DocumentSelection _savedSelection; + SelectionEndpoints _selectionEndpoints; + SelectionEndpoints _savedSelectionEndpoints; + TextSelectType _selectionType = TextSelectType::Letters; style::cursor _cursor = style::cur_default; + DragAction _dragAction = NoDrag; + QPoint _dragStartPosition; + int _dragSegment = -1; + int _dragSymbol = 0; + TextSelection _dragExpandedSelection; + int _lastRelayoutMs = 0; int _zoom = 100; + base::unique_qptr _contextMenu; }; +struct PaintSelectionState { + const std::vector *segments = nullptr; + DocumentSelection selection; + const SelectionEndpoints *endpoints = nullptr; + + [[nodiscard]] bool empty() const { + return !segments || selection.empty(); + } +}; + +[[nodiscard]] int CompareSelectionPositions( + DocumentSelectionPosition a, + DocumentSelectionPosition b) { + if (a.segment != b.segment) { + return (a.segment < b.segment) ? -1 : 1; + } + if (a.offset != b.offset) { + return (a.offset < b.offset) ? -1 : 1; + } + return 0; +} + +[[nodiscard]] DocumentSelection NormalizeSelection( + DocumentSelection selection) { + if (selection.empty()) { + return {}; + } + if (CompareSelectionPositions(selection.from, selection.to) > 0) { + std::swap(selection.from, selection.to); + } + return selection; +} + +[[nodiscard]] SelectionEndpoint MakeSelectionEndpoint( + const DocumentHitTestResult &result) { + return { + .segment = result.segmentIndex, + .direct = result.direct, + }; +} + +[[nodiscard]] const SelectableSegment *FindSegment( + const std::vector *segments, + int index) { + if (!segments || index < 0 || index >= int(segments->size())) { + return nullptr; + } + return &(*segments)[index]; +} + +[[nodiscard]] int LastTableCellSegmentIndex( + const std::vector *segments, + int tableSegmentIndex) { + auto result = tableSegmentIndex; + if (!segments) { + return result; + } + for (const auto &segment : *segments) { + if (segment.tableSegmentIndex == tableSegmentIndex) { + result = std::max(result, segment.index); + } + } + return result; +} + +[[nodiscard]] int SegmentLength(const SelectableSegment &segment) { + return std::max(segment.length, 0); +} + +[[nodiscard]] std::optional SingleTableCellSelection( + const PaintSelectionState &selectionState, + int tableSegmentIndex) { + if (selectionState.empty() + || !selectionState.endpoints + || tableSegmentIndex < 0) { + return std::nullopt; + } + const auto normalized = NormalizeSelection(selectionState.selection); + if (normalized.empty()) { + return std::nullopt; + } + const auto lastCellSegment = LastTableCellSegmentIndex( + selectionState.segments, + tableSegmentIndex); + const auto spansWholeTable = (normalized.from.segment < tableSegmentIndex) + && (normalized.to.segment > lastCellSegment); + auto tableHit = false; + auto cellSegment = -1; + auto multipleCells = false; + const auto consider = [&](SelectionEndpoint endpoint) { + if (!endpoint.valid() || !endpoint.direct) { + return; + } + const auto segment = FindSegment(selectionState.segments, endpoint.segment); + if (!segment) { + return; + } + if (segment->index == tableSegmentIndex) { + tableHit = true; + return; + } + if (segment->tableSegmentIndex != tableSegmentIndex) { + return; + } + if (cellSegment < 0) { + cellSegment = segment->index; + } else if (cellSegment != segment->index) { + multipleCells = true; + } + }; + consider(selectionState.endpoints->from); + consider(selectionState.endpoints->to); + if (tableHit || multipleCells || cellSegment < 0 || spansWholeTable) { + return std::nullopt; + } + return cellSegment; +} + +[[nodiscard]] std::optional BaseTextSelectionForSegment( + const SelectableSegment &segment, + DocumentSelection selection) { + if (selection.empty() || !segment.isTextLeaf()) { + return std::nullopt; + } + selection = NormalizeSelection(selection); + if (selection.empty() + || selection.from.segment > segment.index + || selection.to.segment < segment.index) { + return std::nullopt; + } + auto from = 0; + auto to = SegmentLength(segment); + if (selection.from.segment == segment.index) { + from = selection.from.offset; + } + if (selection.to.segment == segment.index) { + to = selection.to.offset; + } + from = std::clamp(from, 0, SegmentLength(segment)); + to = std::clamp(to, 0, SegmentLength(segment)); + if (from >= to) { + return std::nullopt; + } + return TextSelection(uint16(from), uint16(to)); +} + +[[nodiscard]] bool RangeSelectsWholeSegment( + const SelectableSegment &segment, + DocumentSelection selection) { + selection = NormalizeSelection(selection); + if (selection.empty() + || selection.from.segment > segment.index + || selection.to.segment < segment.index) { + return false; + } + auto from = 0; + auto to = SegmentLength(segment); + if (selection.from.segment == segment.index) { + from = selection.from.offset; + } + if (selection.to.segment == segment.index) { + to = selection.to.offset; + } + from = std::clamp(from, 0, SegmentLength(segment)); + to = std::clamp(to, 0, SegmentLength(segment)); + return (from < to); +} + +[[nodiscard]] bool TableSegmentSelected( + const PaintSelectionState &selectionState, + int tableSegmentIndex) { + if (selectionState.empty() || tableSegmentIndex < 0) { + return false; + } + if (SingleTableCellSelection(selectionState, tableSegmentIndex)) { + return false; + } + const auto normalized = NormalizeSelection(selectionState.selection); + if (normalized.empty()) { + return false; + } + auto selectedCells = 0; + auto selectedCellIndex = -1; + for (const auto &segment : *selectionState.segments) { + if (segment.tableSegmentIndex != tableSegmentIndex + || segment.index == tableSegmentIndex) { + continue; + } + const auto textSelection = BaseTextSelectionForSegment( + segment, + normalized); + if (!textSelection || textSelection->empty()) { + continue; + } + if (++selectedCells == 1) { + selectedCellIndex = segment.index; + } else { + return true; + } + } + const auto table = FindSegment(selectionState.segments, tableSegmentIndex); + if (!table || !RangeSelectsWholeSegment(*table, normalized)) { + return false; + } + if (selectedCells != 1) { + return true; + } + if (normalized.from.segment == tableSegmentIndex + || normalized.to.segment == tableSegmentIndex) { + return true; + } + const auto lower = std::min(tableSegmentIndex, selectedCellIndex); + const auto upper = std::max(tableSegmentIndex, selectedCellIndex); + return (normalized.from.segment < lower) + && (normalized.to.segment > upper); +} + +[[nodiscard]] std::optional TextSelectionForSegment( + const SelectableSegment &segment, + const PaintSelectionState &selectionState) { + if (selectionState.empty()) { + return std::nullopt; + } + if (segment.tableSegmentIndex >= 0) { + if (const auto singleCell = SingleTableCellSelection( + selectionState, + segment.tableSegmentIndex); + singleCell && *singleCell != segment.index) { + return std::nullopt; + } + } + if (segment.tableSegmentIndex >= 0 + && TableSegmentSelected( + selectionState, + segment.tableSegmentIndex)) { + return std::nullopt; + } + return BaseTextSelectionForSegment(segment, selectionState.selection); +} + +[[nodiscard]] std::optional TextSelectionForSegmentIndex( + const PaintSelectionState &selectionState, + int index) { + const auto segment = FindSegment(selectionState.segments, index); + return segment + ? TextSelectionForSegment(*segment, selectionState) + : std::nullopt; +} + +[[nodiscard]] bool WholeSegmentSelected( + const SelectableSegment &segment, + const PaintSelectionState &selectionState) { + if (selectionState.empty() || segment.isTextLeaf()) { + return false; + } + if (segment.kind == SelectableSegmentKind::Table) { + return TableSegmentSelected(selectionState, segment.index); + } + return RangeSelectsWholeSegment(segment, selectionState.selection); +} + +[[nodiscard]] bool WholeSegmentSelected( + const PaintSelectionState &selectionState, + int index) { + const auto segment = FindSegment(selectionState.segments, index); + return segment + ? WholeSegmentSelected(*segment, selectionState) + : false; +} + void PaintTextLeaf( Painter &p, const Ui::Text::String &leaf, QRect rect, int width, QRect clip, - style::align align = style::al_left) { + style::align align = style::al_left, + std::optional selection = std::nullopt) { leaf.draw(p, { .position = rect.topLeft(), .availableWidth = width, @@ -1254,6 +1921,7 @@ void PaintTextLeaf( .palette = &p.textPalette(), .spoiler = Ui::Text::DefaultSpoilerCache(), .now = crl::now(), + .selection = selection.value_or(TextSelection()), }); } @@ -1301,12 +1969,14 @@ void PaintBlocks( Painter &p, const std::vector &blocks, const PreparedResult &prepared, + const PaintSelectionState &selectionState, QRect clip); void PaintTableBlock( Painter &p, const LaidOutBlock &block, const MarkdownStyleSnapshot &style, + const PaintSelectionState &selectionState, QRect clip) { const auto tableClip = clip.intersected(block.visibleTableRect); if (tableClip.isEmpty()) { @@ -1384,10 +2054,18 @@ void PaintTableBlock( cell.textRect, cell.textWidth, tableClip, - cell.align); + cell.align, + TextSelectionForSegmentIndex( + selectionState, + cell.segmentIndex)); } } + if (block.segmentIndex >= 0 + && WholeSegmentSelected(selectionState, block.segmentIndex)) { + p.fillRect(block.visibleTableRect, p.textPalette().selectOverlay); + } + if (block.overflowed) { const auto indicatorWidth = std::min( std::max(style.tableOverflowWidth, 1), @@ -1410,6 +2088,7 @@ void PaintDisplayMathBlock( Painter &p, const LaidOutBlock &block, const PreparedResult &prepared, + const PaintSelectionState &selectionState, QRect clip) { const auto formulaClip = clip.intersected(block.visibleFormulaRect); if (formulaClip.isEmpty()) { @@ -1442,6 +2121,11 @@ void PaintDisplayMathBlock( formulaClip); } + if (block.segmentIndex >= 0 + && WholeSegmentSelected(selectionState, block.segmentIndex)) { + p.fillRect(block.visibleFormulaRect, p.textPalette().selectOverlay); + } + if (block.overflowed) { const auto indicatorWidth = std::min( std::max(style.displayMathOverflowWidth, 1), @@ -1464,6 +2148,7 @@ void PaintBlock( Painter &p, const LaidOutBlock &block, const PreparedResult &prepared, + const PaintSelectionState &selectionState, QRect clip) { if (!block.outer.intersects(clip)) { return; @@ -1474,7 +2159,16 @@ void PaintBlock( case PreparedBlockKind::Paragraph: case PreparedBlockKind::Heading: p.setPen(style.defaultTextColor); - PaintTextLeaf(p, block.leaf, block.textRect, block.textWidth, clip); + PaintTextLeaf( + p, + block.leaf, + block.textRect, + block.textWidth, + clip, + style::al_left, + TextSelectionForSegmentIndex( + selectionState, + block.segmentIndex)); break; case PreparedBlockKind::CodeBlock: { const auto radius = style.codeRadius; @@ -1496,13 +2190,22 @@ void PaintBlock( clip); } p.setPen(style.defaultTextColor); - PaintTextLeaf(p, block.leaf, block.textRect, block.textWidth, clip); + PaintTextLeaf( + p, + block.leaf, + block.textRect, + block.textWidth, + clip, + style::al_left, + TextSelectionForSegmentIndex( + selectionState, + block.segmentIndex)); } break; case PreparedBlockKind::Rule: p.fillRect(block.outer, style.ruleColor); break; case PreparedBlockKind::List: - PaintBlocks(p, block.children, prepared, clip); + PaintBlocks(p, block.children, prepared, selectionState, clip); break; case PreparedBlockKind::ListItem: if (block.taskState != TaskState::None) { @@ -1516,17 +2219,17 @@ void PaintBlock( block.markerWidth, clip); } - PaintBlocks(p, block.children, prepared, clip); + PaintBlocks(p, block.children, prepared, selectionState, clip); break; case PreparedBlockKind::Quote: p.fillRect(block.borderRect, style.quoteBorderColor); - PaintBlocks(p, block.children, prepared, clip); + PaintBlocks(p, block.children, prepared, selectionState, clip); break; case PreparedBlockKind::DisplayMath: - PaintDisplayMathBlock(p, block, prepared, clip); + PaintDisplayMathBlock(p, block, prepared, selectionState, clip); break; case PreparedBlockKind::Table: - PaintTableBlock(p, block, style, clip); + PaintTableBlock(p, block, style, selectionState, clip); break; case PreparedBlockKind::Details: PaintTextLeaf( @@ -1534,8 +2237,12 @@ void PaintBlock( block.leaf, block.textRect, block.textWidth, - clip); - PaintBlocks(p, block.children, prepared, clip); + clip, + style::al_left, + TextSelectionForSegmentIndex( + selectionState, + block.segmentIndex)); + PaintBlocks(p, block.children, prepared, selectionState, clip); break; } } @@ -1544,6 +2251,7 @@ void PaintBlocks( Painter &p, const std::vector &blocks, const PreparedResult &prepared, + const PaintSelectionState &selectionState, QRect clip) { for (const auto &block : blocks) { if (block.outer.bottom() < clip.top()) { @@ -1551,7 +2259,7 @@ void PaintBlocks( } else if (block.outer.top() > clip.bottom()) { break; } - PaintBlock(p, block, prepared, clip); + PaintBlock(p, block, prepared, selectionState, clip); } } @@ -1560,126 +2268,111 @@ void PaintBlocks( QRect rect, int width, QPoint point, - style::align align = style::al_left) { - if (!rect.contains(point)) { + Ui::Text::StateRequest::Flags flags, + style::align align = style::al_left, + bool clampToRect = false) { + if (rect.isEmpty()) { return {}; } + if (!rect.contains(point)) { + if (!clampToRect) { + return {}; + } + point.setX(std::clamp(point.x(), rect.left(), rect.right())); + point.setY(std::clamp(point.y(), rect.top(), rect.bottom())); + } auto request = Ui::Text::StateRequest(); request.align = align; - request.flags |= Ui::Text::StateRequest::Flag::BreakEverywhere; + request.flags = flags | Ui::Text::StateRequest::Flag::BreakEverywhere; return leaf.getState( point - rect.topLeft(), TextGeometry(width), request); } -[[nodiscard]] ClickHandlerPtr LinkAtTextLeaf( - const Ui::Text::String &leaf, - QRect rect, - int width, +[[nodiscard]] DocumentHitTestResult HitSegmentBoundary( + const SelectableSegment &segment, + int offset) { + auto result = DocumentHitTestResult(); + result.segmentIndex = segment.index; + result.forcedOffset = std::clamp(offset, 0, SegmentLength(segment)); + result.state.uponSymbol = true; + result.state.afterSymbol = (result.forcedOffset > 0); + return result; +} + +[[nodiscard]] DocumentHitTestResult HitTextSegment( + const SelectableSegment &segment, QPoint point, - style::align align = style::al_left) { - const auto state = TextStateAtLeaf(leaf, rect, width, point, align); - return state.link; -} - -[[nodiscard]] ClickHandlerPtr LinkAtTextBlock( - const LaidOutBlock &block, - QPoint point) { - return LinkAtTextLeaf( - block.leaf, - block.textRect, - block.textWidth, - point); -} - -[[nodiscard]] ClickHandlerPtr LinkAtTableCell( - const LaidOutTableCell &cell, - QPoint point) { - return LinkAtTextLeaf( - cell.leaf, - cell.textRect, - cell.textWidth, + Ui::Text::StateRequest::Flags flags) { + if (!segment.isTextLeaf() || !segment.outerRect.contains(point)) { + return {}; + } + const auto insideText = segment.textRect.contains(point); + if (!insideText + && !(flags & Ui::Text::StateRequest::Flag::LookupSymbol)) { + return {}; + } + auto result = DocumentHitTestResult(); + result.segmentIndex = segment.index; + result.state = TextStateAtLeaf( + *segment.leaf, + segment.textRect, + segment.textWidth, point, - cell.align); + flags, + segment.align, + !insideText); + if (!insideText) { + result.state.link = nullptr; + } + result.direct = true; + return result; } -[[nodiscard]] ClickHandlerPtr LinkAtTableBlock( - const LaidOutBlock &block, +[[nodiscard]] DocumentHitTestResult HitBlockSegment( + const SelectableSegment &segment, + QPoint point, + Ui::Text::StateRequest::Flags flags) { + if (segment.isTextLeaf() + || !(flags & Ui::Text::StateRequest::Flag::LookupSymbol) + || !segment.outerRect.contains(point)) { + return {}; + } + const auto after = (point.y() > segment.outerRect.center().y()) + || ((point.y() == segment.outerRect.center().y()) + && (point.x() >= segment.outerRect.center().x())); + auto result = HitSegmentBoundary( + segment, + after ? SegmentLength(segment) : 0); + result.direct = true; + return result; +} + +[[nodiscard]] DocumentHitTestResult HitSegmentFallback( + const std::vector &segments, QPoint point) { - const auto visibleTableRect = block.visibleTableRect; - for (const auto &row : block.tableRows) { - if (!row.outer.intersects(visibleTableRect)) { - continue; - } else if (row.outer.bottom() < point.y()) { - continue; - } else if (row.outer.top() > point.y()) { - break; + if (segments.empty()) { + return {}; + } + for (const auto &segment : segments) { + const auto &rect = segment.outerRect; + if (point.y() < rect.top()) { + return HitSegmentBoundary(segment, 0); } - for (const auto &cell : row.cells) { - if (!cell.outer.intersects(visibleTableRect)) { - continue; - } else if (cell.outer.right() < point.x()) { - continue; - } else if (cell.outer.left() > point.x()) { - break; - } - if (const auto result = LinkAtTableCell(cell, point)) { - return result; + if (point.y() <= rect.bottom()) { + if (point.x() < rect.left()) { + return HitSegmentBoundary(segment, 0); + } else if (point.x() > rect.right()) { + return HitSegmentBoundary( + segment, + SegmentLength(segment)); } } } - return nullptr; -} - -[[nodiscard]] ClickHandlerPtr LinkAtBlocks( - const std::vector &blocks, - QPoint point); - -[[nodiscard]] ClickHandlerPtr LinkAtBlock( - const LaidOutBlock &block, - QPoint point) { - if (!block.outer.contains(point)) { - return nullptr; - } - switch (block.kind) { - case PreparedBlockKind::Paragraph: - case PreparedBlockKind::Heading: - return LinkAtTextBlock(block, point); - case PreparedBlockKind::List: - case PreparedBlockKind::ListItem: - case PreparedBlockKind::Quote: - return LinkAtBlocks(block.children, point); - case PreparedBlockKind::DisplayMath: - return nullptr; - case PreparedBlockKind::Table: - return LinkAtTableBlock(block, point); - case PreparedBlockKind::Details: - if (const auto result = LinkAtTextBlock(block, point)) { - return result; - } - return LinkAtBlocks(block.children, point); - case PreparedBlockKind::CodeBlock: - case PreparedBlockKind::Rule: - return nullptr; - } - return nullptr; -} - -[[nodiscard]] ClickHandlerPtr LinkAtBlocks( - const std::vector &blocks, - QPoint point) { - for (const auto &block : blocks) { - if (block.outer.bottom() < point.y()) { - continue; - } else if (block.outer.top() > point.y()) { - break; - } - if (const auto result = LinkAtBlock(block, point)) { - return result; - } - } - return nullptr; + return HitSegmentBoundary( + segments.back(), + SegmentLength(segments.back())); } void CollectAnchors( @@ -1706,6 +2399,7 @@ void DocumentLayout::relayout( _width = width; _blocks.clear(); _anchors.clear(); + _segments.clear(); const auto &page = prepared.style.pagePadding; const auto innerWidth = std::max(width - page.left() - page.right(), 1); @@ -1720,6 +2414,7 @@ void DocumentLayout::relayout( {}); _height = y + page.bottom(); CollectAnchors(_blocks, &_anchors); + CollectSelectableSegments(&_blocks, &_segments); } void DocumentLayout::invalidate() { @@ -1727,6 +2422,7 @@ void DocumentLayout::invalidate() { _height = 0; _blocks.clear(); _anchors.clear(); + _segments.clear(); } int DocumentLayout::height() const { @@ -1746,6 +2442,35 @@ const std::vector &DocumentLayout::blocks() const { return _blocks; } +const std::vector &DocumentLayout::segments() const { + return _segments; +} + +const SelectableSegment *DocumentLayout::segment(int index) const { + return FindSegment(&_segments, index); +} + +DocumentHitTestResult DocumentLayout::hitTest( + QPoint point, + Ui::Text::StateRequest::Flags flags) const { + for (const auto &segment : _segments) { + if (const auto result = HitTextSegment(segment, point, flags); + result.valid()) { + return result; + } + } + for (const auto &segment : _segments) { + if (const auto result = HitBlockSegment(segment, point, flags); + result.valid()) { + return result; + } + } + if (flags & Ui::Text::StateRequest::Flag::LookupSymbol) { + return HitSegmentFallback(_segments, point); + } + return {}; +} + [[nodiscard]] bool ToggleDetailsBlock( std::vector *blocks, const QString &anchorId) { @@ -1776,6 +2501,7 @@ MarkdownDocumentWidget::MarkdownDocumentWidget( QWidget *parent) : Ui::RpWidget(parent) { setMouseTracking(true); + setFocusPolicy(Qt::StrongFocus); } void MarkdownDocumentWidget::setLinkActivationCallback( @@ -1787,8 +2513,10 @@ void MarkdownDocumentWidget::setPreparedResult(PreparedResult prepared) { ClickHandler::clearActive(this); applyCursor(style::cur_default); _layout.invalidate(); + _lastRelayoutMs = 0; _prepared = std::move(prepared); _textPalette.emplace(_prepared.style.textPalette); + resetSelection(); forceRelayoutCurrentWidth(); } @@ -1798,6 +2526,7 @@ void MarkdownDocumentWidget::setZoom(int value) { return; } _zoom = value; + clearSelection(); forceRelayoutCurrentWidth(); } @@ -1813,17 +2542,26 @@ bool MarkdownDocumentWidget::toggleDetails(const QString &anchorId) { if (!ToggleDetailsBlock(&_prepared.blocks.blocks, anchorId)) { return false; } + clearSelection(); _layout.invalidate(); forceRelayoutCurrentWidth(); return true; } +int MarkdownDocumentWidget::lastRelayoutMs() const { + return _lastRelayoutMs; +} + int MarkdownDocumentWidget::resizeGetHeight(int newWidth) { ClickHandler::clearActive(this); applyCursor(style::cur_default); + clearSelection(); const auto scale = zoomScale(); const auto layoutWidth = std::max(int(std::floor(newWidth / scale)), 1); + auto timer = QElapsedTimer(); + timer.start(); _layout.relayout(_prepared, layoutWidth); + _lastRelayoutMs = int(timer.elapsed()); return std::max(int(std::ceil(_layout.height() * scale)), 1); } @@ -1832,10 +2570,20 @@ void MarkdownDocumentWidget::paintEvent(QPaintEvent *e) { if (_textPalette) { p.setTextPalette(_textPalette->palette); } + const auto selectionState = PaintSelectionState{ + .segments = &_layout.segments(), + .selection = _selection, + .endpoints = &_selectionEndpoints, + }; const auto scale = zoomScale(); if (scale == 1.) { - PaintBlocks(p, _layout.blocks(), _prepared, e->rect()); + PaintBlocks( + p, + _layout.blocks(), + _prepared, + selectionState, + e->rect()); return; } const auto clip = QRect( @@ -1845,46 +2593,187 @@ void MarkdownDocumentWidget::paintEvent(QPaintEvent *e) { int(std::ceil(e->rect().height() / scale)) + 1); p.save(); p.scale(scale, scale); - PaintBlocks(p, _layout.blocks(), _prepared, clip); + PaintBlocks(p, _layout.blocks(), _prepared, selectionState, clip); p.restore(); } +void MarkdownDocumentWidget::keyPressEvent(QKeyEvent *e) { + if (e == QKeySequence::Copy && !selectionForCopy().empty()) { + copySelectedText(); + return; + } + Ui::RpWidget::keyPressEvent(e); +} + +void MarkdownDocumentWidget::contextMenuEvent(QContextMenuEvent *e) { + const auto globalPoint = (e->reason() == QContextMenuEvent::Mouse) + ? e->globalPos() + : QCursor::pos(); + const auto localPoint = (e->reason() == QContextMenuEvent::Mouse) + ? e->pos() + : mapFromGlobal(globalPoint); + const auto state = hitTest( + localPoint, + Ui::Text::StateRequest::Flag::LookupLink + | Ui::Text::StateRequest::Flag::LookupSymbol); + const auto selection = selectionForCopy(); + const auto uponSelection = !selection.empty() + && ((e->reason() != QContextMenuEvent::Mouse) + || selectionContains(selection, state)); + const auto contextText = uponSelection ? TextForMimeData() : textForContext(state); + const auto link = state.direct + ? std::dynamic_pointer_cast(state.state.link) + : nullptr; + + _contextMenu = base::make_unique_q(this); + if (uponSelection) { + _contextMenu->addAction( + Ui::Integration::Instance().phraseContextCopySelected(), + [=] { copySelectedText(); }, + &st::menuIconCopy); + } else if (!contextText.empty()) { + _contextMenu->addAction( + tr::lng_context_copy_text(tr::now), + [text = contextText] { + TextUtilities::SetClipboardText(text); + }, + &st::menuIconCopy); + } + + if (link) { + if (const auto label = link->copyToClipboardContextItemText(); + !label.isEmpty()) { + _contextMenu->addAction( + label, + [text = link->copyToClipboardText()] { + QGuiApplication::clipboard()->setText(text); + }, + &st::menuIconCopy); + } + switch (link->link().kind) { + case PreparedLinkKind::RejectedRelative: + case PreparedLinkKind::ToggleDetails: + break; + case PreparedLinkKind::External: + case PreparedLinkKind::Anchor: + case PreparedLinkKind::Footnote: + case PreparedLinkKind::FootnoteBacklink: + case PreparedLinkKind::LocalFile: + _contextMenu->addAction( + tr::lng_open_link(tr::now), + [=, prepared = link->link()] { + if (_activateLink) { + _activateLink(prepared, Qt::LeftButton); + } + }, + &st::menuIconAddress); + break; + } + } + + if (_contextMenu->empty()) { + _contextMenu = nullptr; + return; + } + _contextMenu->popup(globalPoint); + e->accept(); +} + void MarkdownDocumentWidget::mouseMoveEvent(QMouseEvent *e) { - updateHover(e->pos()); + dragActionUpdate(e->pos()); } void MarkdownDocumentWidget::mousePressEvent(QMouseEvent *e) { - updateHover(e->pos()); - if (e->button() == Qt::LeftButton || e->button() == Qt::MiddleButton) { + if (e->button() == Qt::LeftButton) { + dragActionStart(e->pos(), e->button()); + return; + } + updateHover(hitTest( + e->pos(), + Ui::Text::StateRequest::Flag::LookupLink + | Ui::Text::StateRequest::Flag::LookupSymbol)); + if (e->button() == Qt::MiddleButton) { ClickHandler::pressed(); } } void MarkdownDocumentWidget::mouseReleaseEvent(QMouseEvent *e) { - const auto activated = ClickHandler::unpressed(); - if (activated - && (e->button() == Qt::LeftButton - || e->button() == Qt::MiddleButton)) { - if (const auto prepared = std::dynamic_pointer_cast( - activated)) { - if (_activateLink) { - _activateLink(prepared->link(), e->button()); - } - } else { - ActivateClickHandler(window(), activated, e->button()); - } - } - if (rect().contains(e->pos())) { - updateHover(e->pos()); - } else { + dragActionFinish(e->pos(), e->button()); + if (!rect().contains(e->pos())) { ClickHandler::clearActive(this); applyCursor(style::cur_default); } } -void MarkdownDocumentWidget::leaveEventHook(QEvent *e) { +void MarkdownDocumentWidget::mouseDoubleClickEvent(QMouseEvent *e) { + dragActionStart(e->pos(), e->button()); + if (_dragAction != Selecting || _selectionType != TextSelectType::Letters) { + return; + } + const auto state = hitTest( + e->pos(), + Ui::Text::StateRequest::Flag::LookupLink + | Ui::Text::StateRequest::Flag::LookupSymbol); + const auto segment = _layout.segment(state.segmentIndex); + if (!segment + || !segment->isTextLeaf() + || !state.direct + || !state.state.uponSymbol) { + return; + } + _dragSegment = state.segmentIndex; + _dragSymbol = std::clamp( + int(state.state.symbol), + 0, + SegmentLength(*segment)); + _selectionType = TextSelectType::Words; + _selection = selectionFromHit(state); + _savedSelection = {}; + _selectionEndpoints = { + .from = MakeSelectionEndpoint(state), + .to = MakeSelectionEndpoint(state), + }; + _savedSelectionEndpoints = {}; + if (_selection.from.segment == _dragSegment + && _selection.to.segment == _dragSegment) { + _dragExpandedSelection = TextSelection( + uint16(_selection.from.offset), + uint16(_selection.to.offset)); + } + setFocus(); + updateHover(state); + update(); +} + +void MarkdownDocumentWidget::focusOutEvent(QFocusEvent *e) { + if (!_selection.empty()) { + _savedSelection = _selection; + _savedSelectionEndpoints = _selectionEndpoints; + _selection = {}; + _selectionEndpoints = {}; + update(); + } ClickHandler::clearActive(this); applyCursor(style::cur_default); + Ui::RpWidget::focusOutEvent(e); +} + +void MarkdownDocumentWidget::focusInEvent(QFocusEvent *e) { + if (!_savedSelection.empty()) { + _selection = _savedSelection; + _selectionEndpoints = _savedSelectionEndpoints; + _savedSelection = {}; + _savedSelectionEndpoints = {}; + update(); + } + Ui::RpWidget::focusInEvent(e); +} + +void MarkdownDocumentWidget::leaveEventHook(QEvent *e) { + ClickHandler::clearActive(this); + applyCursor((_dragAction == Selecting) + ? style::cur_text + : style::cur_default); Ui::RpWidget::leaveEventHook(e); } @@ -1901,19 +2790,218 @@ void MarkdownDocumentWidget::clickHandlerPressedChanged( } ClickHandlerPtr MarkdownDocumentWidget::linkAt(QPoint point) const { + return hitTest( + point, + Ui::Text::StateRequest::Flag::LookupLink + | Ui::Text::StateRequest::Flag::LookupSymbol).state.link; +} + +DocumentHitTestResult MarkdownDocumentWidget::hitTest( + QPoint point, + Ui::Text::StateRequest::Flags flags) const { const auto scale = zoomScale(); if (scale != 1.) { point = QPoint( int(std::floor(point.x() / scale)), int(std::floor(point.y() / scale))); } - return LinkAtBlocks(_layout.blocks(), point); + return _layout.hitTest(point, flags); +} + +DocumentSelection MarkdownDocumentWidget::selectionForCopy() const { + return !_selection.empty() + ? _selection + : _contextMenu + ? _savedSelection + : DocumentSelection(); +} + +SelectionEndpoints MarkdownDocumentWidget::selectionEndpointsForCopy() const { + return !_selection.empty() + ? _selectionEndpoints + : _contextMenu + ? _savedSelectionEndpoints + : SelectionEndpoints(); +} + +bool MarkdownDocumentWidget::selectionContains( + DocumentSelection selection, + const DocumentHitTestResult &result) const { + const auto segment = _layout.segment(result.segmentIndex); + if (!segment || selection.empty() || !result.valid()) { + return false; + } + const auto endpoints = selectionEndpointsForCopy(); + const auto selectionState = PaintSelectionState{ + .segments = &_layout.segments(), + .selection = selection, + .endpoints = &endpoints, + }; + if (segment->tableSegmentIndex >= 0 + && TableSegmentSelected(selectionState, segment->tableSegmentIndex)) { + return true; + } + if (!segment->isTextLeaf()) { + return WholeSegmentSelected(*segment, selectionState); + } + const auto textSelection = TextSelectionForSegment(*segment, selectionState); + if (!textSelection || textSelection->empty()) { + return false; + } + const auto offset = selectionOffsetFromHit(result); + return (offset >= textSelection->from) && (offset < textSelection->to); +} + +TextForMimeData MarkdownDocumentWidget::textForSegment( + const SelectableSegment &segment, + TextSelection selection) const { + switch (segment.kind) { + case SelectableSegmentKind::TextLeaf: + return segment.leaf + ? segment.leaf->toTextForMimeData(selection) + : TextForMimeData(); + case SelectableSegmentKind::CodeBlock: + return segment.block + ? CopyTextForCodeBlock(*segment.block, selection) + : TextForMimeData(); + case SelectableSegmentKind::DisplayMath: + return segment.block + ? CopyTextForDisplayMath(*segment.block) + : TextForMimeData(); + case SelectableSegmentKind::Table: + return segment.block + ? CopyTextForTable(*segment.block) + : TextForMimeData(); + } + return TextForMimeData(); +} + +TextForMimeData MarkdownDocumentWidget::textForContext( + const DocumentHitTestResult &result) const { + if (!result.valid() || !result.direct) { + return TextForMimeData(); + } + const auto segment = _layout.segment(result.segmentIndex); + if (!segment) { + return TextForMimeData(); + } + return textForSegment(*segment); +} + +int MarkdownDocumentWidget::selectionOffsetFromHit( + const DocumentHitTestResult &result) const { + const auto segment = _layout.segment(result.segmentIndex); + if (!segment) { + return 0; + } + if (result.forcedOffset >= 0) { + return std::clamp(result.forcedOffset, 0, SegmentLength(*segment)); + } + auto offset = int(result.state.symbol); + if (_selectionType == TextSelectType::Letters + && result.state.afterSymbol) { + ++offset; + } + return std::clamp(offset, 0, SegmentLength(*segment)); +} + +DocumentSelection MarkdownDocumentWidget::selectionFromHit( + const DocumentHitTestResult &result) const { + if (_dragSegment < 0 || !result.valid()) { + return {}; + } + auto first = _dragSymbol; + auto second = selectionOffsetFromHit(result); + if (_selectionType != TextSelectType::Letters + && !_dragExpandedSelection.empty() + && result.segmentIndex != _dragSegment) { + first = (CompareSelectionPositions( + DocumentSelectionPosition{ result.segmentIndex, second }, + DocumentSelectionPosition{ _dragSegment, _dragSymbol }) < 0) + ? _dragExpandedSelection.to + : _dragExpandedSelection.from; + } + if (result.segmentIndex == _dragSegment) { + if (const auto segment = _layout.segment(_dragSegment); + segment && segment->isTextLeaf()) { + const auto adjusted = segment->leaf->adjustSelection( + TextSelection( + uint16(std::min(first, second)), + uint16(std::max(first, second))), + _selectionType); + return { + { _dragSegment, adjusted.from }, + { _dragSegment, adjusted.to }, + }; + } + } + return NormalizeSelection({ + { _dragSegment, first }, + { result.segmentIndex, second }, + }); +} + +TextForMimeData MarkdownDocumentWidget::getSelectedText() const { + const auto selection = selectionForCopy(); + if (selection.empty()) { + return TextForMimeData(); + } + const auto endpoints = selectionEndpointsForCopy(); + const auto selectionState = PaintSelectionState{ + .segments = &_layout.segments(), + .selection = selection, + .endpoints = &endpoints, + }; + auto pieces = std::vector(); + for (const auto &segment : _layout.segments()) { + if (segment.isTextLeaf()) { + if (const auto textSelection = TextSelectionForSegment( + segment, + selectionState); + textSelection && !textSelection->empty()) { + if (auto text = textForSegment(segment, *textSelection); + !text.empty()) { + pieces.push_back(std::move(text)); + } + } + continue; + } + if (!WholeSegmentSelected(segment, selectionState)) { + continue; + } + if (auto text = textForSegment(segment); !text.empty()) { + pieces.push_back(std::move(text)); + } + } + if (pieces.empty()) { + return TextForMimeData(); + } else if (pieces.size() == 1) { + return std::move(pieces.front()); + } + auto result = TextForMimeData(); + for (auto i = 0, count = int(pieces.size()); i != count; ++i) { + if (i) { + result.append(u"\n"_q); + } + result.append(std::move(pieces[i])); + } + return result; +} + +void MarkdownDocumentWidget::copySelectedText() { + if (const auto text = getSelectedText(); !text.empty()) { + TextUtilities::SetClipboardText(text); + } } void MarkdownDocumentWidget::relayoutCurrentWidth() { + clearSelection(); const auto scale = zoomScale(); const auto layoutWidth = std::max(int(std::floor(width() / scale)), 1); + auto timer = QElapsedTimer(); + timer.start(); _layout.relayout(_prepared, layoutWidth); + _lastRelayoutMs = int(timer.elapsed()); } void MarkdownDocumentWidget::forceRelayoutCurrentWidth() { @@ -1921,11 +3009,160 @@ void MarkdownDocumentWidget::forceRelayoutCurrentWidth() { update(); } -void MarkdownDocumentWidget::updateHover(QPoint point) { - ClickHandler::setActive(linkAt(point), this); - applyCursor(ClickHandler::getActive() - ? style::cur_pointer - : style::cur_default); +void MarkdownDocumentWidget::updateHover(const DocumentHitTestResult &state) { + const auto changed = ClickHandler::setActive(state.state.link, this); + auto cursor = style::cur_default; + if (_dragAction == NoDrag) { + if (state.state.link) { + cursor = style::cur_pointer; + } else if (state.direct) { + cursor = style::cur_text; + } + } else { + if (_dragAction == Selecting) { + const auto selection = selectionFromHit(state); + const auto endpoints = SelectionEndpoints{ + .from = _selectionEndpoints.from.valid() + ? _selectionEndpoints.from + : SelectionEndpoint{ _dragSegment, false }, + .to = MakeSelectionEndpoint(state), + }; + const auto endpointsChanged + = (_selectionEndpoints.from.segment != endpoints.from.segment) + || (_selectionEndpoints.from.direct != endpoints.from.direct) + || (_selectionEndpoints.to.segment != endpoints.to.segment) + || (_selectionEndpoints.to.direct != endpoints.to.direct); + if (_selection != selection || endpointsChanged) { + _selection = selection; + _selectionEndpoints = endpoints; + _savedSelection = {}; + _savedSelectionEndpoints = {}; + setFocus(); + update(); + } else { + _selectionEndpoints = endpoints; + } + cursor = style::cur_text; + } else if (ClickHandler::getPressed()) { + cursor = style::cur_pointer; + } + } + if (changed || cursor != _cursor) { + applyCursor(cursor); + } +} + +void MarkdownDocumentWidget::resetSelection() { + _selection = {}; + _savedSelection = {}; + _selectionEndpoints = {}; + _savedSelectionEndpoints = {}; + _selectionType = TextSelectType::Letters; + _dragAction = NoDrag; + _dragStartPosition = QPoint(); + _dragSegment = -1; + _dragSymbol = 0; + _dragExpandedSelection = {}; +} + +void MarkdownDocumentWidget::clearSelection() { + const auto hadSelection = !_selection.empty() + || !_savedSelection.empty() + || (_dragAction != NoDrag); + resetSelection(); + if (hadSelection) { + update(); + } +} + +void MarkdownDocumentWidget::dragActionStart( + QPoint point, + Qt::MouseButton button) { + const auto state = hitTest( + point, + Ui::Text::StateRequest::Flag::LookupLink + | Ui::Text::StateRequest::Flag::LookupSymbol); + updateHover(state); + if (button != Qt::LeftButton) { + return; + } + ClickHandler::pressed(); + _dragAction = NoDrag; + _dragExpandedSelection = {}; + _dragSegment = -1; + _dragSymbol = 0; + if (ClickHandler::getPressed()) { + _dragStartPosition = point; + _dragAction = PrepareDrag; + return; + } + if (!state.valid()) { + clearSelection(); + return; + } + _dragSegment = state.segmentIndex; + _dragSymbol = selectionOffsetFromHit(state); + _selection = { + { _dragSegment, _dragSymbol }, + { _dragSegment, _dragSymbol }, + }; + _savedSelection = {}; + _selectionEndpoints = { + .from = MakeSelectionEndpoint(state), + .to = MakeSelectionEndpoint(state), + }; + _savedSelectionEndpoints = {}; + _dragAction = Selecting; + update(); +} + +DocumentHitTestResult MarkdownDocumentWidget::dragActionUpdate(QPoint point) { + const auto state = hitTest( + point, + Ui::Text::StateRequest::Flag::LookupLink + | Ui::Text::StateRequest::Flag::LookupSymbol); + if (_dragAction == PrepareDrag + && (point - _dragStartPosition).manhattanLength() + >= QApplication::startDragDistance()) { + _dragAction = Dragging; + } + updateHover(state); + return state; +} + +DocumentHitTestResult MarkdownDocumentWidget::dragActionFinish( + QPoint point, + Qt::MouseButton button) { + const auto state = dragActionUpdate(point); + auto activated = ClickHandler::unpressed(); + if (_dragAction == Dragging + || (_dragAction == Selecting && !_selection.empty())) { + activated = nullptr; + } else if (_dragAction == PrepareDrag && button != Qt::RightButton) { + clearSelection(); + } + _dragAction = NoDrag; + _selectionType = TextSelectType::Letters; + _dragExpandedSelection = {}; + updateHover(state); + if (activated + && (button == Qt::LeftButton || button == Qt::MiddleButton)) { + if (const auto prepared = std::dynamic_pointer_cast< + PreparedLinkClickHandler>(activated)) { + if (_activateLink) { + _activateLink(prepared->link(), button); + } + } else { + ActivateClickHandler(window(), activated, button); + } + } + if (QGuiApplication::clipboard()->supportsSelection() + && !_selection.empty()) { + if (const auto text = getSelectedText(); !text.empty()) { + TextUtilities::SetClipboardText(text, QClipboard::Selection); + } + } + return state; } void MarkdownDocumentWidget::applyCursor(style::cursor cursor) { @@ -1943,6 +3180,30 @@ constexpr auto kDeferredPreparationSourceBytes = 128 * 1024; constexpr auto kDeferredPreparationFormulaCount = 4; constexpr auto kDeferredPreparationConvertedNodes = 1200; +[[nodiscard]] QString PrepareTerminalFailureName( + PrepareTerminalFailure failure) { + switch (failure) { + case PrepareTerminalFailure::None: + return u"none"_q; + case PrepareTerminalFailure::InvalidRequest: + return u"invalid-request"_q; + case PrepareTerminalFailure::InvalidStyle: + return u"invalid-style"_q; + case PrepareTerminalFailure::DocumentTooLarge: + return u"document-too-large"_q; + case PrepareTerminalFailure::InternalError: + return u"internal-error"_q; + } + return u"unknown"_q; +} + +[[nodiscard]] QString PrepareFailureReasonText( + const PrepareFailureStatus &failure) { + return !failure.debugReason.isEmpty() + ? failure.debugReason + : PrepareTerminalFailureName(failure.terminal); +} + class MarkdownPreviewRoot final : public Ui::RpWidget { public: MarkdownPreviewRoot( @@ -1956,12 +3217,19 @@ private: void startPreparation( bool deferred, - std::optional style = std::nullopt); + std::optional style = std::nullopt, + bool clearRendererCache = false); void activateLink(const PreparedLink &link, Qt::MouseButton button); void applyPreparedResult(PreparedResult prepared); [[nodiscard]] bool scrollToAnchor(const QString &anchorId); void updateChildrenGeometry(QSize size); void updateLoadingGeometry(); + void updateFailureGeometry(); + void logPreparationSummary( + const PrepareFailureStatus &failure, + const PrepareDebugStats &debug, + int prepareMs, + int layoutMs) const; void cancelInFlightRequest(); const OpenOptions _options; @@ -1969,10 +3237,16 @@ private: Ui::ScrollArea *_scroll = nullptr; MarkdownDocumentWidget *_body = nullptr; Ui::FlatLabel *_loading = nullptr; + Ui::FlatLabel *_failure = nullptr; + Ui::LinkButton *_failureOpen = nullptr; + std::shared_ptr _renderer; PrepareGeneration _generation = 0; int _requestedDevicePixelRatio = 0; QString _pendingFragment; std::shared_ptr _cancelled; + QElapsedTimer _prepareTimer; + PrepareGeneration _prepareTimerGeneration = 0; + bool _prepareTimerActive = false; }; @@ -1983,6 +3257,7 @@ MarkdownPreviewRoot::MarkdownPreviewRoot( : Ui::RpWidget(parent) , _options(options) , _document(std::make_shared(document)) +, _renderer(std::make_shared()) , _pendingFragment(options.initialFragment) , _cancelled(std::make_shared(false)) { _scroll = Ui::CreateChild(this, st::boxScroll); @@ -1991,6 +3266,13 @@ MarkdownPreviewRoot::MarkdownPreviewRoot( this, tr::lng_contacts_loading(tr::now), st::membersAbout); + _failure = Ui::CreateChild( + this, + tr::lng_markdown_preview_cant(tr::now), + st::ivMarkdownFailureLabel); + _failureOpen = Ui::CreateChild( + this, + tr::lng_markdown_preview_open_file(tr::now)); _scroll->hide(); if (_body) { @@ -2005,6 +3287,13 @@ MarkdownPreviewRoot::MarkdownPreviewRoot( } } _loading->hide(); + _failure->hide(); + _failureOpen->hide(); + _failureOpen->setClickedCallback([=] { + if (!_options.sourcePath.isEmpty()) { + File::Launch(_options.sourcePath); + } + }); const auto initialStyle = CaptureMarkdownStyleSnapshot(); _requestedDevicePixelRatio = initialStyle.devicePixelRatio; @@ -2014,7 +3303,7 @@ MarkdownPreviewRoot::MarkdownPreviewRoot( }, lifetime()); style::PaletteChanged() | rpl::on_next([=] { - startPreparation(shouldDeferPreparation()); + startPreparation(shouldDeferPreparation(), std::nullopt, true); }, lifetime()); screenValue() | rpl::on_next([=](not_null) { @@ -2022,7 +3311,7 @@ MarkdownPreviewRoot::MarkdownPreviewRoot( if (style.devicePixelRatio == _requestedDevicePixelRatio) { return; } - startPreparation(shouldDeferPreparation(), std::move(style)); + startPreparation(shouldDeferPreparation(), std::move(style), true); }, lifetime()); if (_options.delegate) { @@ -2050,7 +3339,8 @@ bool MarkdownPreviewRoot::shouldDeferPreparation() const { void MarkdownPreviewRoot::startPreparation( bool deferred, - std::optional style) { + std::optional style, + bool clearRendererCache) { cancelInFlightRequest(); _cancelled = std::make_shared(false); @@ -2063,8 +3353,18 @@ void MarkdownPreviewRoot::startPreparation( style = CaptureMarkdownStyleSnapshot(); } _requestedDevicePixelRatio = style->devicePixelRatio; + if (_renderer) { + if (clearRendererCache) { + _renderer->clearCache(); + } + _renderer->resetDebugCounters(); + } + _prepareTimer.start(); + _prepareTimerGeneration = generation; + _prepareTimerActive = true; auto request = PrepareRequest{ .document = _document, + .renderer = _renderer, .style = std::move(*style), .generation = generation, .sourcePath = _options.sourcePath, @@ -2076,11 +3376,15 @@ void MarkdownPreviewRoot::startPreparation( if (_body) { _body->hide(); } + _failure->hide(); + _failureOpen->hide(); _loading->show(); _loading->raise(); updateLoadingGeometry(); } else { _loading->hide(); + _failure->hide(); + _failureOpen->hide(); } const auto weak = base::make_weak(this); @@ -2147,22 +3451,58 @@ void MarkdownPreviewRoot::activateLink( } void MarkdownPreviewRoot::applyPreparedResult(PreparedResult prepared) { - if (!_body) { + const auto prepareMs = (_prepareTimerActive + && (prepared.generation == _prepareTimerGeneration)) + ? int(_prepareTimer.elapsed()) + : prepared.debug.prepareMs; + _prepareTimerActive = false; + + const auto failure = prepared.failure; + const auto debug = prepared.debug; + if (failure.failed()) { + _scroll->hide(); + if (_body) { + _body->hide(); + } + _loading->hide(); + _failure->show(); + if (_options.sourcePath.isEmpty()) { + _failureOpen->hide(); + } else { + _failureOpen->show(); + } + _failure->raise(); + _failureOpen->raise(); + updateFailureGeometry(); + logPreparationSummary(failure, debug, prepareMs, 0); return; } + + if (!_body) { + logPreparationSummary(failure, debug, prepareMs, 0); + return; + } + + updateChildrenGeometry(size()); _body->setPreparedResult(std::move(prepared)); if (_options.delegate) { _body->setZoom(_options.delegate->ivZoom()); } - _body->resizeToWidth(_scroll->width()); _scroll->show(); _body->show(); _loading->hide(); + _failure->hide(); + _failureOpen->hide(); if (!_pendingFragment.isEmpty()) { const auto scrolled = scrollToAnchor(_pendingFragment); static_cast(scrolled); _pendingFragment.clear(); } + logPreparationSummary( + failure, + debug, + prepareMs, + _body->lastRelayoutMs()); } bool MarkdownPreviewRoot::scrollToAnchor(const QString &anchorId) { @@ -2183,6 +3523,7 @@ void MarkdownPreviewRoot::updateChildrenGeometry(QSize size) { _body->resizeToWidth(_scroll->width()); } updateLoadingGeometry(); + updateFailureGeometry(); } void MarkdownPreviewRoot::updateLoadingGeometry() { @@ -2194,6 +3535,55 @@ void MarkdownPreviewRoot::updateLoadingGeometry() { availableWidth); } +void MarkdownPreviewRoot::updateFailureGeometry() { + const auto availableWidth = std::max(width(), 1); + const auto failureWidth = std::min(availableWidth, st::ivMarkdownFailureWidth); + _failure->resizeToWidth(failureWidth); + _failureOpen->resizeToNaturalWidth(failureWidth); + const auto openVisible = !_failureOpen->isHidden(); + const auto totalHeight = _failure->height() + + (openVisible ? (st::ivMarkdownFailureSkip + _failureOpen->height()) : 0); + const auto top = std::max((height() - totalHeight) / 2, 0); + _failure->moveToLeft( + (availableWidth - failureWidth) / 2, + top, + availableWidth); + if (openVisible) { + _failureOpen->moveToLeft( + (availableWidth - _failureOpen->width()) / 2, + top + _failure->height() + st::ivMarkdownFailureSkip, + availableWidth); + } +} + +void MarkdownPreviewRoot::logPreparationSummary( + const PrepareFailureStatus &failure, + const PrepareDebugStats &debug, + int prepareMs, + int layoutMs) const { +#ifndef NDEBUG + const auto counters = _renderer ? _renderer->debugCounters() : FormulaDebugCounters(); + const auto reason = PrepareFailureReasonText(failure); + DEBUG_LOG(( + failure.failed() + ? "Native Markdown IV: unexpected preview prepare failure (%1 ms prepare, %2 ms layout, %3 ms formulas, cache hits=%4 misses=%5 bytes=%6, terminal=%7): %8" + : "Native Markdown IV: preview prepare success (%1 ms prepare, %2 ms layout, %3 ms formulas, cache hits=%4 misses=%5 bytes=%6, terminal=%7): %8" + ).arg(prepareMs + ).arg(layoutMs + ).arg(debug.formulaRenderMs + ).arg(counters.hits + ).arg(counters.misses + ).arg(qlonglong(counters.cacheBytes) + ).arg(reason + ).arg(_options.sourcePath)); +#else + Q_UNUSED(failure); + Q_UNUSED(debug); + Q_UNUSED(prepareMs); + Q_UNUSED(layoutMs); +#endif +} + void MarkdownPreviewRoot::cancelInFlightRequest() { if (_cancelled) { _cancelled->store(true, std::memory_order_relaxed); diff --git a/Telegram/SourceFiles/tests/test_markdown_iv.cpp b/Telegram/SourceFiles/tests/test_markdown_iv.cpp index 9b09b83056..b2e6968304 100644 --- a/Telegram/SourceFiles/tests/test_markdown_iv.cpp +++ b/Telegram/SourceFiles/tests/test_markdown_iv.cpp @@ -1,5 +1,11 @@ #include "iv/markdown/iv_markdown_document.h" +#include "iv/markdown/iv_markdown_math_renderer.h" +#include "iv/markdown/iv_markdown_microtex.h" #include "iv/markdown/iv_markdown_parse.h" +#include "iv/markdown/iv_markdown_prepare.h" + +#include "ui/style/style_core.h" +#include "ui/style/style_core_scale.h" #include #include @@ -8,19 +14,21 @@ #include #include #include +#include +#include + +#include #include #include #include +#include #include namespace { using namespace Iv::Markdown; -constexpr auto kValidationSourceLimit = 4 * 1024 * 1024; -constexpr auto kValidationFormulaLimit = 64 * 1024; - struct Args { QString markdownPath; QString latexMarkdownPath; @@ -30,6 +38,13 @@ struct Args { QString error; }; +struct PreparedFixture { + QString label; + QString path; + PreparedDocument parsed; + PreparedResult prepared; +}; + [[nodiscard]] QString FromLatin1(const char *value) { return QString::fromLatin1(value); } @@ -733,6 +748,135 @@ void PrintSummary(const PreparedDocument &document, const QString &label) { return true; } +[[nodiscard]] QString AbsolutePath(const QString &path) { + return QDir::cleanPath(QFileInfo(path).absoluteFilePath()); +} + +[[nodiscard]] QString PrepareFailureReason( + const PrepareFailureStatus &failure) { + return !failure.debugReason.isEmpty() + ? failure.debugReason + : QString::number(int(failure.terminal)); +} + +[[nodiscard]] PreparedResult PrepareParsedDocumentForTest( + const PreparedDocument &document, + const QString &sourcePath, + const std::shared_ptr &renderer, + MarkdownStyleSnapshot style = CaptureMarkdownStyleSnapshot()) { + return PrepareSynchronously({ + .document = std::make_shared(document), + .renderer = renderer, + .style = std::move(style), + .generation = 1, + .sourcePath = AbsolutePath(sourcePath), + .cancelled = std::make_shared(false), + }); +} + +[[nodiscard]] int CountPreparedFormulaSlots(const PreparedResult &prepared) { + auto result = 0; + for (const auto &slot : prepared.formulas) { + if (slot.present) { + ++result; + } + } + return result; +} + +void PrintPrepareSummary( + const QString &label, + const PreparedResult &prepared) { + auto line = label; + line.append(FromLatin1(" prepare_ms=")); + line.append(QString::number(prepared.debug.prepareMs)); + line.append(FromLatin1(" formula_ms=")); + line.append(QString::number(prepared.debug.formulaRenderMs)); + line.append(FromLatin1(" prepare_warnings=")); + line.append(QString::number(prepared.debug.prepareWarningCount)); + line.append(FromLatin1(" formula_warnings=")); + line.append(QString::number(prepared.debug.formulaWarningCount)); + line.append(FromLatin1(" prepared_formulas=")); + line.append(QString::number(CountPreparedFormulaSlots(prepared))); + PrintLine(line); +} + +[[nodiscard]] bool PrepareFixture( + const QString &path, + const QString &label, + const std::shared_ptr &renderer, + PreparedFixture *fixture) { + auto parsed = PreparedDocument(); + if (!ParseFixture(path, label, &parsed)) { + return false; + } + auto prepared = PrepareParsedDocumentForTest(parsed, path, renderer); + if (prepared.cancelled) { + PrintError(label + FromLatin1(" prepare-cancelled")); + return false; + } + if (prepared.failure.failed()) { + PrintError( + label + FromLatin1(" prepare-failed: ") + + PrepareFailureReason(prepared.failure)); + return false; + } + PrintPrepareSummary(label, prepared); + if (fixture) { + fixture->label = label; + fixture->path = AbsolutePath(path); + fixture->parsed = std::move(parsed); + fixture->prepared = std::move(prepared); + } + return true; +} + +template +void ForEachPreparedBlock( + const std::vector &blocks, + Callback &&callback) { + for (const auto &block : blocks) { + callback(block); + ForEachPreparedBlock(block.children, callback); + } +} + +template +void ForEachPreparedLink( + const std::vector &blocks, + Callback &&callback) { + ForEachPreparedBlock(blocks, [&](const PreparedBlock &block) { + for (const auto &link : block.links) { + callback(link); + } + for (const auto &row : block.tableRows) { + for (const auto &cell : row.cells) { + for (const auto &link : cell.links) { + callback(link); + } + } + } + }); +} + +template +void ForEachPreparedInlineObject( + const std::vector &blocks, + Callback &&callback) { + ForEachPreparedBlock(blocks, [&](const PreparedBlock &block) { + for (const auto &object : block.inlineObjects) { + callback(object); + } + for (const auto &row : block.tableRows) { + for (const auto &cell : row.cells) { + for (const auto &object : cell.inlineObjects) { + callback(object); + } + } + } + }); +} + void Check(bool condition, const QString &message, bool *ok) { if (condition) { return; @@ -776,6 +920,7 @@ void CheckParseFailure( } void CheckValidationEdges(bool *ok) { + const auto &limits = ParseLimitsForIv(); auto utf8BomSource = QByteArray::fromHex("EFBBBF"); utf8BomSource.append("# Title\n"); auto validatedUtf8Bom = CheckValidationSuccess( @@ -831,7 +976,7 @@ void CheckValidationEdges(bool *ok) { FromLatin1("source-invalid-utf8"), ok); - const auto oversizedSource = QByteArray(kValidationSourceLimit + 1, 'a'); + const auto oversizedSource = QByteArray(limits.maxSourceBytes + 1, 'a'); CheckValidationFailure( oversizedSource, FromLatin1("source size"), @@ -844,9 +989,9 @@ void CheckValidationEdges(bool *ok) { ok); auto oversizedFormula = QByteArray(); - oversizedFormula.reserve(kValidationFormulaLimit + 2); + oversizedFormula.reserve(limits.maxFormulaBytes + 2); oversizedFormula.append('$'); - oversizedFormula.append(QByteArray(kValidationFormulaLimit + 1, '+')); + oversizedFormula.append(QByteArray(limits.maxFormulaBytes + 1, '+')); oversizedFormula.append('$'); CheckParseFailure( oversizedFormula, @@ -1138,12 +1283,493 @@ void CheckFixtureSemanticCoverage( } -} // namespace +void CheckPrepareCoverage( + const PreparedFixture &markdownFixture, + const PreparedFixture &latexFixture, + bool *ok) { + const auto &markdown = markdownFixture.prepared; + const auto &latex = latexFixture.prepared; -int main(int argc, char **argv) { - auto application = QCoreApplication(argc, argv); - (void)application; + auto inlineCopySourceFound = false; + ForEachPreparedInlineObject(markdown.blocks.blocks, [&](const PreparedInlineObject &object) { + if (object.copySource == FromLatin1("$a^2 + b^2 = c^2$")) { + inlineCopySourceFound = true; + } + }); + Check( + inlineCopySourceFound, + FromLatin1("markdown-example.md prepared inline formula copySource"), + ok); + auto markdownTables = std::vector(); + const PreparedBlock *markdownDisplayMath = nullptr; + const PreparedBlock *detailsBlock = nullptr; + const PreparedBlock *footnoteList = nullptr; + ForEachPreparedBlock(markdown.blocks.blocks, [&](const PreparedBlock &block) { + if (block.kind == PreparedBlockKind::Table) { + markdownTables.push_back(&block); + } + if (!markdownDisplayMath + && block.kind == PreparedBlockKind::DisplayMath + && block.formulaTex.trimmed() + == FromLatin1("\\int_0^1 x^2\\,dx = \\frac{1}{3}")) { + markdownDisplayMath = █ + } + if (!detailsBlock + && block.kind == PreparedBlockKind::Details + && block.text.text.contains( + FromLatin1("Click to expand details/summary block"))) { + detailsBlock = █ + } + if (!footnoteList + && block.kind == PreparedBlockKind::List + && block.listKind == ListKind::Ordered + && block.children.size() >= 2 + && block.children[0].anchorId == FromLatin1("fn-1") + && block.children[1].anchorId == FromLatin1("fn-2")) { + footnoteList = █ + } + }); + Check( + markdownDisplayMath != nullptr, + FromLatin1("markdown-example.md prepared display math formulaTex"), + ok); + Check( + markdownTables.size() >= 2, + FromLatin1("markdown-example.md prepared table count"), + ok); + if (markdownTables.size() >= 2) { + const auto &firstTable = *markdownTables[0]; + const auto &secondTable = *markdownTables[1]; + Check( + firstTable.tableColumnCount == 3, + FromLatin1("markdown-example.md prepared first table column count"), + ok); + Check( + firstTable.tableRows.size() == 4, + FromLatin1("markdown-example.md prepared first table row count"), + ok); + if (firstTable.tableRows.size() == 4) { + Check( + firstTable.tableRows[0].header, + FromLatin1("markdown-example.md prepared first table header row"), + ok); + Check( + firstTable.tableRows[0].cells.size() == 3 + && firstTable.tableRows[1].cells.size() == 3, + FromLatin1("markdown-example.md prepared first table cell shape"), + ok); + } + Check( + secondTable.tableAlignments.size() == 3 + && secondTable.tableAlignments[0] == TableAlignment::Left + && secondTable.tableAlignments[1] == TableAlignment::Center + && secondTable.tableAlignments[2] == TableAlignment::Right, + FromLatin1("markdown-example.md prepared second table alignments"), + ok); + } + Check( + detailsBlock != nullptr, + FromLatin1("markdown-example.md prepared details block"), + ok); + if (detailsBlock) { + Check( + detailsBlock->collapsed, + FromLatin1("markdown-example.md prepared details collapsed"), + ok); + Check( + !detailsBlock->children.empty() + && detailsBlock->children[0].kind == PreparedBlockKind::Paragraph + && detailsBlock->children[0].text.text.contains( + FromLatin1("Hidden content inside details.")), + FromLatin1("markdown-example.md prepared details body"), + ok); + } + Check( + footnoteList != nullptr, + FromLatin1("markdown-example.md prepared footnote list"), + ok); + auto footnoteReferenceOne = false; + auto footnoteReferenceTwo = false; + auto footnoteBacklinkFound = false; + ForEachPreparedLink(markdown.blocks.blocks, [&](const PreparedLink &link) { + if (link.kind == PreparedLinkKind::Footnote + && link.target == FromLatin1("fn-1")) { + footnoteReferenceOne = true; + } + if (link.kind == PreparedLinkKind::Footnote + && link.target == FromLatin1("fn-2")) { + footnoteReferenceTwo = true; + } + if (link.kind == PreparedLinkKind::FootnoteBacklink + && !link.target.isEmpty()) { + footnoteBacklinkFound = true; + } + }); + Check( + footnoteReferenceOne && footnoteReferenceTwo, + FromLatin1("markdown-example.md prepared footnote references"), + ok); + Check( + footnoteBacklinkFound, + FromLatin1("markdown-example.md prepared footnote backlink"), + ok); + + auto latexTables = std::vector(); + auto latexTableCopySourceFound = false; + ForEachPreparedBlock(latex.blocks.blocks, [&](const PreparedBlock &block) { + if (block.kind == PreparedBlockKind::Table) { + latexTables.push_back(&block); + } + }); + ForEachPreparedInlineObject(latex.blocks.blocks, [&](const PreparedInlineObject &object) { + if (object.copySource == FromLatin1("$x^n$") + || object.copySource + == FromLatin1("$\\frac{x^{n+1}}{n+1}$")) { + latexTableCopySourceFound = true; + } + }); + Check( + !latexTables.empty(), + FromLatin1("latex-markdown-test.md prepared table count"), + ok); + if (!latexTables.empty()) { + const auto &table = *latexTables[0]; + Check( + table.tableColumnCount == 3, + FromLatin1("latex-markdown-test.md prepared table column count"), + ok); + Check( + table.tableRows.size() == 5, + FromLatin1("latex-markdown-test.md prepared table row count"), + ok); + if (table.tableRows.size() == 5) { + Check( + table.tableRows[0].header, + FromLatin1("latex-markdown-test.md prepared table header row"), + ok); + Check( + table.tableRows[1].cells.size() == 3, + FromLatin1("latex-markdown-test.md prepared table cell count"), + ok); + } + } + Check( + latexTableCopySourceFound, + FromLatin1("latex-markdown-test.md prepared table inline formula copySource"), + ok); + + const auto &limits = PrepareTableRenderLimitsForIv(); + auto overflowTable = QByteArray("| A | B |\n| --- | --- |\n"); + for (auto i = 0; i != limits.maxRows; ++i) { + overflowTable.append("| row "); + overflowTable.append(QByteArray::number(i)); + overflowTable.append(" | value |\n"); + } + const auto overflowLabel = FromLatin1("generated-overflow-table.md"); + const auto overflowParsed = ParseMarkdownForIv( + overflowTable, + ParseOptions{ overflowLabel }); + Check( + overflowParsed.ok, + overflowLabel + FromLatin1(" parse failed: ") + overflowParsed.error, + ok); + if (overflowParsed.ok) { + const auto overflowPrepared = PrepareParsedDocumentForTest( + overflowParsed.document, + overflowLabel, + std::make_shared()); + Check( + !overflowPrepared.cancelled, + overflowLabel + FromLatin1(" prepare cancelled"), + ok); + Check( + !overflowPrepared.failure.failed(), + overflowLabel + FromLatin1(" prepare failure: ") + + PrepareFailureReason(overflowPrepared.failure), + ok); + auto overflowTableBlocks = 0; + ForEachPreparedBlock( + overflowPrepared.blocks.blocks, + [&](const PreparedBlock &block) { + if (block.kind == PreparedBlockKind::Table) { + ++overflowTableBlocks; + } + }); + Check( + overflowPrepared.debug.prepareWarningCount > 0, + overflowLabel + FromLatin1(" flatten warning count"), + ok); + Check( + overflowTableBlocks == 0, + overflowLabel + FromLatin1(" flattened table block removed"), + ok); + Check( + !overflowPrepared.blocks.blocks.empty(), + overflowLabel + FromLatin1(" flattened fallback blocks present"), + ok); + } +} + +void CheckPrepareLinkClassification( + const QString &sourcePath, + bool *ok) { + const auto label = FromLatin1("generated-relative-links.md"); + const auto parsed = ParseMarkdownForIv( + QByteArray( + "[Local](./docs/getting-started.md#section-1)\n" + "[Rejected](../outside.md)\n"), + ParseOptions{ label }); + Check( + parsed.ok, + label + FromLatin1(" parse failed: ") + parsed.error, + ok); + if (!parsed.ok) { + return; + } + const auto prepared = PrepareParsedDocumentForTest( + parsed.document, + sourcePath, + std::make_shared()); + Check( + !prepared.cancelled, + label + FromLatin1(" prepare cancelled"), + ok); + Check( + !prepared.failure.failed(), + label + FromLatin1(" prepare failure: ") + + PrepareFailureReason(prepared.failure), + ok); + const auto expectedLocalTarget = QDir( + QFileInfo(sourcePath).absolutePath()).absoluteFilePath( + FromLatin1("docs/getting-started.md")); + auto foundLocal = false; + auto foundRejected = false; + ForEachPreparedLink(prepared.blocks.blocks, [&](const PreparedLink &link) { + if (link.kind == PreparedLinkKind::LocalFile + && link.target == QDir::cleanPath(expectedLocalTarget) + && link.fragment == FromLatin1("section-1") + && link.copyText + == FromLatin1("./docs/getting-started.md#section-1")) { + foundLocal = true; + } + if (link.kind == PreparedLinkKind::RejectedRelative + && link.copyText == FromLatin1("../outside.md")) { + foundRejected = true; + } + }); + Check( + foundLocal, + FromLatin1("generated-relative-links.md local markdown classification"), + ok); + Check( + foundRejected, + FromLatin1("generated-relative-links.md rejected relative classification"), + ok); +} + +void CheckPrepareRenderSmoke( + const PreparedFixture &markdownFixture, + const PreparedFixture &latexFixture, + bool *ok) { + Check( + MicrotexBackendLinked(), + FromLatin1("microtex backend should be linked"), + ok); + auto renderer = std::make_shared(); + const auto firstMarkdown = PrepareParsedDocumentForTest( + markdownFixture.parsed, + markdownFixture.path, + renderer); + const auto firstLatex = PrepareParsedDocumentForTest( + latexFixture.parsed, + latexFixture.path, + renderer); + Check( + !firstMarkdown.cancelled && !firstLatex.cancelled, + FromLatin1("prepare cache smoke first pass cancelled"), + ok); + Check( + !firstMarkdown.failure.failed() && !firstLatex.failure.failed(), + FromLatin1("prepare cache smoke first pass failed"), + ok); + const auto firstCounters = renderer->debugCounters(); + Check( + renderer->cacheUsageBytes() > 0, + FromLatin1("prepare cache smoke first pass cache bytes"), + ok); + renderer->resetDebugCounters(); + const auto secondMarkdown = PrepareParsedDocumentForTest( + markdownFixture.parsed, + markdownFixture.path, + renderer); + const auto secondLatex = PrepareParsedDocumentForTest( + latexFixture.parsed, + latexFixture.path, + renderer); + Check( + !secondMarkdown.cancelled && !secondLatex.cancelled, + FromLatin1("prepare cache smoke second pass cancelled"), + ok); + Check( + !secondMarkdown.failure.failed() && !secondLatex.failure.failed(), + FromLatin1("prepare cache smoke second pass failed"), + ok); + const auto secondCounters = renderer->debugCounters(); + const auto expectedHits = CountPreparedFormulaSlots(firstMarkdown) + + CountPreparedFormulaSlots(firstLatex); + Check( + secondCounters.hits >= expectedHits, + FromLatin1("prepare cache smoke second pass cache hits"), + ok); + Check( + secondCounters.misses == 0, + FromLatin1("prepare cache smoke second pass cache misses"), + ok); + auto smokeLine = FromLatin1("prepare-cache-smoke"); + smokeLine.append(FromLatin1(" first_hits=")); + smokeLine.append(QString::number(firstCounters.hits)); + smokeLine.append(FromLatin1(" first_misses=")); + smokeLine.append(QString::number(firstCounters.misses)); + smokeLine.append(FromLatin1(" second_hits=")); + smokeLine.append(QString::number(secondCounters.hits)); + smokeLine.append(FromLatin1(" second_misses=")); + smokeLine.append(QString::number(secondCounters.misses)); + smokeLine.append(FromLatin1(" cache_bytes=")); + smokeLine.append(QString::number(secondCounters.cacheBytes)); + PrintLine(smokeLine); + + const auto failureLabel = FromLatin1("generated-formula-cap.md"); + const auto failureParsed = ParseMarkdownForIv( + QByteArray("$$\nE = mc^2\n$$\n"), + ParseOptions{ failureLabel }); + Check( + failureParsed.ok, + failureLabel + FromLatin1(" parse failed: ") + failureParsed.error, + ok); + if (!failureParsed.ok) { + return; + } + auto failureStyle = CaptureMarkdownStyleSnapshot(); + failureStyle.displayMathMaxRenderWidth = 1; + const auto failurePrepared = PrepareParsedDocumentForTest( + failureParsed.document, + failureLabel, + std::make_shared(), + std::move(failureStyle)); + Check( + !failurePrepared.cancelled, + failureLabel + FromLatin1(" prepare cancelled"), + ok); + Check( + !failurePrepared.failure.failed(), + failureLabel + FromLatin1(" terminal prepare failure"), + ok); + Check( + failurePrepared.debug.formulaWarningCount > 0, + failureLabel + FromLatin1(" formula warning count"), + ok); + auto failedFormulaFound = false; + for (const auto &slot : failurePrepared.formulas) { + if (!slot.present) { + continue; + } + if (!slot.rendered.success + && (slot.rendered.tooLarge || slot.rendered.overflow)) { + failedFormulaFound = true; + } + } + Check( + failedFormulaFound, + failureLabel + FromLatin1(" formula cap fallback result"), + ok); + + const auto &prepareLimits = PrepareLimitsForIv(); + auto blockLimitSource = QByteArray(); + for (auto i = 0; i != (prepareLimits.maxPreparedBlocks + 1); ++i) { + blockLimitSource.append("Paragraph "); + blockLimitSource.append(QByteArray::number(i)); + blockLimitSource.append("\n\n"); + } + const auto blockLimitLabel = FromLatin1("generated-prepare-block-limit.md"); + const auto blockLimitParsed = ParseMarkdownForIv( + blockLimitSource, + ParseOptions{ blockLimitLabel }); + Check( + blockLimitParsed.ok, + blockLimitLabel + FromLatin1(" parse failed: ") + blockLimitParsed.error, + ok); + if (blockLimitParsed.ok) { + const auto blockLimitPrepared = PrepareParsedDocumentForTest( + blockLimitParsed.document, + blockLimitLabel, + std::make_shared()); + Check( + !blockLimitPrepared.cancelled, + blockLimitLabel + FromLatin1(" prepare cancelled"), + ok); + Check( + blockLimitPrepared.failure.failed(), + blockLimitLabel + FromLatin1( + " missing real terminal prepare failure"), + ok); + Check( + blockLimitPrepared.failure.terminal + == PrepareTerminalFailure::DocumentTooLarge, + blockLimitLabel + FromLatin1( + " real terminal prepare failure kind"), + ok); + Check( + PrepareFailureReason(blockLimitPrepared.failure) + == FromLatin1("prepared-block-limit"), + blockLimitLabel + FromLatin1( + " real terminal prepare failure reason"), + ok); + Check( + blockLimitPrepared.blocks.blocks.empty(), + blockLimitLabel + FromLatin1( + " real terminal prepare clears blocks"), + ok); + } + + const auto invalidStyleLabel = FromLatin1( + "generated-invalid-style-internal.md"); + auto invalidStyle = CaptureMarkdownStyleSnapshot(); + invalidStyle.devicePixelRatio = 0; + const auto invalidStylePrepared = PrepareParsedDocumentForTest( + markdownFixture.parsed, + markdownFixture.path, + std::make_shared(), + std::move(invalidStyle)); + Check( + !invalidStylePrepared.cancelled, + invalidStyleLabel + FromLatin1(" synthetic prepare cancelled"), + ok); + Check( + invalidStylePrepared.failure.failed(), + invalidStyleLabel + FromLatin1( + " missing synthetic terminal prepare failure"), + ok); + Check( + invalidStylePrepared.failure.terminal + == PrepareTerminalFailure::InvalidStyle, + invalidStyleLabel + FromLatin1( + " synthetic terminal prepare failure kind"), + ok); + Check( + PrepareFailureReason(invalidStylePrepared.failure) + == FromLatin1("invalid-device-pixel-ratio"), + invalidStyleLabel + FromLatin1( + " synthetic terminal prepare failure reason"), + ok); + Check( + invalidStylePrepared.blocks.blocks.empty(), + invalidStyleLabel + FromLatin1( + " synthetic terminal prepare clears blocks"), + ok); +} + +[[nodiscard]] int RunTests(int argc, char **argv) { auto args = ParseArgs(argc, argv); if (!args.ok) { PrintError(args.error); @@ -1162,28 +1788,33 @@ int main(int argc, char **argv) { FromLatin1("latex-markdown-test.md")); } - auto markdown = PreparedDocument(); - if (!ParseFixture( + auto fixtureRenderer = std::make_shared(); + auto markdownFixture = PreparedFixture(); + if (!PrepareFixture( args.markdownPath, FromLatin1("markdown-example.md"), - &markdown)) { + fixtureRenderer, + &markdownFixture)) { return 1; } if (args.dump) { - PrintLine(DumpForDebug(markdown)); + PrintLine(DumpForDebug(markdownFixture.parsed)); } - auto latex = PreparedDocument(); - if (!ParseFixture( + auto latexFixture = PreparedFixture(); + if (!PrepareFixture( args.latexMarkdownPath, FromLatin1("latex-markdown-test.md"), - &latex)) { + fixtureRenderer, + &latexFixture)) { return 1; } if (args.dump) { - PrintLine(DumpForDebug(latex)); + PrintLine(DumpForDebug(latexFixture.parsed)); } + const auto &markdown = markdownFixture.parsed; + const auto &latex = latexFixture.parsed; auto ok = true; Check( markdown.stats.cmarkNodeCount == 562, @@ -1528,8 +2159,33 @@ int main(int argc, char **argv) { FromLatin1("latex-markdown-test.md lines 332-340 exclusions"), &ok); + CheckPrepareCoverage(markdownFixture, latexFixture, &ok); + CheckPrepareLinkClassification(markdownFixture.path, &ok); + CheckPrepareRenderSmoke(markdownFixture, latexFixture, &ok); CheckInlineHtmlCoverage(args.dump, &ok); CheckValidationEdges(&ok); return ok ? 0 : 1; } + +} // namespace + +int main(int argc, char **argv) { + QCoreApplication::setAttribute(Qt::AA_Use96Dpi); + auto application = QGuiApplication(argc, argv); + (void)application; + + style::SetDevicePixelRatio(1); + style::StartManager(style::kScaleDefault); + const auto result = RunTests(argc, argv); + style::StopManager(); + return result; +} + +namespace crl { + +rpl::producer<> on_main_update_requests() { + return rpl::never<>(); +} + +} // namespace crl diff --git a/Telegram/cmake/tests.cmake b/Telegram/cmake/tests.cmake index 13e9bb9ebf..25e19c2d03 100644 --- a/Telegram/cmake/tests.cmake +++ b/Telegram/cmake/tests.cmake @@ -47,7 +47,13 @@ if (TDESKTOP_NATIVE_MARKDOWN_IV) add_executable(test_markdown_iv) init_target(test_markdown_iv "(tests)") - target_include_directories(test_markdown_iv PRIVATE ${src_loc}) + target_precompile_headers(test_markdown_iv PRIVATE ${src_loc}/iv/iv_pch.h) + + target_include_directories(test_markdown_iv PRIVATE + ${src_loc} + ${CMAKE_BINARY_DIR}/Telegram/gen + ${CMAKE_BINARY_DIR}/Telegram/lib_ui/gen + ) nice_target_sources(test_markdown_iv ${src_loc} PRIVATE @@ -58,8 +64,21 @@ if (TDESKTOP_NATIVE_MARKDOWN_IV) iv/markdown/iv_markdown_document.h iv/markdown/iv_markdown_math.cpp iv/markdown/iv_markdown_math.h + iv/markdown/iv_markdown_math_renderer.cpp + iv/markdown/iv_markdown_math_renderer.h + iv/markdown/iv_markdown_microtex.cpp + iv/markdown/iv_markdown_microtex.h iv/markdown/iv_markdown_parse.cpp iv/markdown/iv_markdown_parse.h + iv/markdown/iv_markdown_prepare.cpp + iv/markdown/iv_markdown_prepare.h + ) + + target_sources(test_markdown_iv PRIVATE + ${CMAKE_BINARY_DIR}/Telegram/gen/styles/style_iv.cpp + ${CMAKE_BINARY_DIR}/Telegram/lib_ui/gen/styles/palette.cpp + ${CMAKE_BINARY_DIR}/Telegram/lib_ui/gen/styles/style_basic.cpp + ${CMAKE_BINARY_DIR}/Telegram/lib_ui/gen/styles/style_widgets.cpp ) target_compile_definitions(test_markdown_iv @@ -70,11 +89,17 @@ if (TDESKTOP_NATIVE_MARKDOWN_IV) target_link_libraries(test_markdown_iv PRIVATE desktop-app::external_cmark_gfm + desktop-app::external_microtex desktop-app::external_qt + desktop-app::external_qt_static_plugins desktop-app::lib_base + desktop-app::lib_crl + desktop-app::lib_tl + desktop-app::lib_ui ) set_target_properties(test_markdown_iv PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) add_dependencies(Telegram test_markdown_iv) + add_dependencies(test_markdown_iv lib_ui_styles td_scheme_scheme td_ui_styles) endif()