From 4370a45728428f6d460fac0aeacaa408f2dee9ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Mon, 1 Jun 2026 16:53:45 -0300 Subject: [PATCH] CSS `white-space-collapse` Support in HTML compatibility layer. --- .../white_space_collapse_support_plan.md | 89 ++- bin/unit_tests/assets/html/pre.code.2.html | 22 + bin/unit_tests/assets/html/pre.code.html | 6 + .../assets/html/pre_code_block.html | 82 +++ include/eepp/graphics/richtext.hpp | 42 ++ include/eepp/ui/css/propertydefinition.hpp | 1 + include/eepp/ui/uirichtext.hpp | 16 +- src/eepp/graphics/richtext.cpp | 320 +++++++-- src/eepp/ui/css/stylesheetspecification.cpp | 1 + src/eepp/ui/uirichtext.cpp | 641 +++++++++++++++--- src/eepp/ui/uitextspan.cpp | 10 +- src/eepp/ui/uiwidgetcreator.cpp | 2 +- src/tests/unit_tests/richtext_tests.cpp | 208 ++++++ src/tests/unit_tests/uihtml_tests.cpp | 362 +++++++++- src/tests/unit_tests/uitextnode_tests.cpp | 2 +- 15 files changed, 1624 insertions(+), 180 deletions(-) create mode 100644 bin/unit_tests/assets/html/pre.code.2.html create mode 100644 bin/unit_tests/assets/html/pre.code.html create mode 100644 bin/unit_tests/assets/html/pre_code_block.html diff --git a/.agent/plans/white_space_collapse_support_plan.md b/.agent/plans/white_space_collapse_support_plan.md index e9d810834..b54d831a1 100644 --- a/.agent/plans/white_space_collapse_support_plan.md +++ b/.agent/plans/white_space_collapse_support_plan.md @@ -13,7 +13,64 @@ Reference specs: - CSS Text Module Level 4: https://drafts.csswg.org/css-text-4/#white-space-collapsing - Stable TR snapshot: https://www.w3.org/TR/css-text-4/#white-space-collapsing -## Current State +## Implementation Status + +Recovered and implemented in the current workspace on 2026-06-01, without refreshing any golden +images. + +Implemented: + +- Raw PCDATA is preserved when loading `UIRichText` and `UITextSpan`; CSS whitespace processing now + happens during RichText rebuild. +- `WhiteSpaceCollapse::Discard` exists and `white-space-collapse` parsing/stringification covers + `collapse`, `preserve`, `preserve-breaks`, `preserve-spaces`, `break-spaces`, and `discard`. +- `white-space` accepts the legacy values plus direct collapse keywords and `wrap`/`nowrap` tokens. +- `UIRichText::rebuildRichText()` applies the effective collapse mode for text nodes, inline spans, + and rebuilt `UITextSpan` roots. +- `RichText` has a whitespace wrap mode for preserved and `break-spaces` content; `break-spaces` + contributes preserved spaces/tabs to intrinsic width and wraps after preserved spaces/tabs. +- CSS `tab-size` is registered as inherited with initial value `8`; `UIRichText` stores the value and + passes it to `RichText`/`Text` measurement and wrapping. +- Base HTML CSS now includes `pre { white-space: pre; }`. +- Generic HTML `
` renders as normal RichText content.
+- The old read-only `UICodeEditor` initialization is retained only when a `
` block is
+  inside `UI_TYPE_MARKDOWNVIEW`, or when `UIRichText::setUseCodeEditorForPreCodeBlocks( true )` is
+  explicitly enabled. The global switch defaults to disabled.
+- Regression fixtures were restored:
+  - `bin/unit_tests/assets/html/pre.code.html`
+  - `bin/unit_tests/assets/html/pre.code.2.html`
+  - `bin/unit_tests/assets/html/pre_code_block.html`
+- A final segment break immediately before an element end tag is discarded before CSS whitespace
+  collapse/transformation, matching browser behavior for preformatted HTML such as
+  `
...\n
`. + +Validated: + +- `make -C make/linux -j$(nproc)` +- `UIHTML.PreCode*` +- `UIHTML.WhiteSpaceCollapse*` +- `UIRichText.WhiteSpaceCollapse*` +- `RichText.BreakSpaces*` +- `UITextNode_EdgeCases.EmptyTextNodesDontAffectLayout` +- `UIHTML.WhiteSpaceNowrap*` +- `UIHTML.AnchorsSizing` +- `UIHTMLTable.complexLayout*` against the original existing golden images +- Full Xvfb/ASAN unit suite: + `ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tests/eepp-unit_tests-debug` + ran 478 test cases: 477 passed, 1 skipped (`UIHTML.redditOldThreadWebViewSmoke`). +- `git diff --check` +- No golden images were refreshed for this feature. + +Pending implementation work: + +- None for the `white-space-collapse` support described by this plan. + +Known follow-up, not part of this recovery: + +- Browser-parity investigation for apparent high pixel-density `em`/font-size differences in code + blocks. At pixel density 1, the current `pre_code_block.html` spacing is covered by unit tests. + +## Baseline State Before This Plan - `UIRichText` already stores: - `WhiteSpaceCollapse::Collapse` @@ -21,17 +78,17 @@ Reference specs: - `WhiteSpaceCollapse::PreserveBreaks` - `WhiteSpaceCollapse::PreserveSpaces` - `WhiteSpaceCollapse::BreakSpaces` -- CSS parsing already recognizes `white-space` and `white-space-collapse`. +- CSS parsing already recognized `white-space` and `white-space-collapse`. - `white-space: nowrap` is partially working because it maps to `mLineWrap = false`. -- The actual text processing is incomplete: +- The actual text processing was incomplete: - `UIRichText::loadFromXmlNode()` collapses all PCDATA through `UIRichText::collapseInternalWhitespace()` before style resolution can know the effective `white-space-collapse` value. - `UIRichText::rebuildRichText()` only distinguishes `Collapse` from all other values. - `preserve-breaks`, `preserve-spaces`, and `break-spaces` do not get their spec behavior. - `break-spaces` has no special line-breaking or intrinsic-size support. -- `
` is special-cased in `UIRichText::loadFromXmlNode()` into a read-only `UICodeEditor`.
-  That workaround blocks normal HTML styling/layout for code blocks and should become unnecessary.
+- `
` was special-cased in `UIRichText::loadFromXmlNode()` into a read-only `UICodeEditor`.
+  That workaround is now limited to markdown/global opt-in only.
 
 ## Spec Behavior To Support
 
@@ -215,20 +272,22 @@ Steps:
 5. Keep `white-space-trim` out of scope unless it already exists elsewhere; explicitly document this
    limitation near `applyWhiteSpace()` or in `html-layout-architecture.md`.
 
-### Phase 6: Replace The `
` Fallback
+### Phase 6: Replace The Generic `
` Fallback
 
 **File:** `src/eepp/ui/uirichtext.cpp`
 
 Once the whitespace behavior is covered:
 
-1. Remove the `mTag == "pre" && child.name() == "code"` branch that creates `UICodeEditor`.
-2. Let `` be created through `UIWidgetCreator::createFromName()` or the existing unknown-element
+1. Remove the generic `mTag == "pre" && child.name() == "code"` branch that creates `UICodeEditor`.
+2. Keep the same `UICodeEditor` path only for `UI_TYPE_MARKDOWNVIEW` ancestors and for the explicit
+   global opt-in switch.
+3. Let ordinary HTML `` be created through `UIWidgetCreator::createFromName()` or the existing
    fallback to `UITextSpan`.
-3. Ensure base HTML CSS gives the right defaults:
+4. Ensure base HTML CSS gives the right defaults:
    - `pre { white-space: pre; }` or equivalent.
    - `code { font-family: monospace; }` if not already present.
    - `pre code` remains normal inline/flow content inside the `
`.
-4. Preserve `data-language` as ordinary data/style metadata. Do not reintroduce syntax-highlighting
+5. Preserve `data-language` as ordinary data/style metadata. Do not reintroduce syntax-highlighting
    behavior through the generic HTML path in this feature.
 
 ### Phase 7: Tests
@@ -255,7 +314,7 @@ pixel-only assertions unless testing trailing-space width.
 Add focused tests in `src/tests/unit_tests/uihtml_tests.cpp`:
 
 - `WhiteSpaceCollapsePreCodePreservesIndentation`
-- `WhiteSpaceCollapsePreCodeUsesRichTextNotCodeEditor`
+- `PreCodeUsesCodeEditorOnlyForMarkdownAncestorOrGlobalOptIn`
 - `WhiteSpaceCollapsePreLinePreservesBreaksOnly`
 - `WhiteSpaceCollapseBreakSpacesAffectsIntrinsicWidth`
 
@@ -270,7 +329,8 @@ For the `
` regression:
 
 Verify:
 
-- `code` is not a `UICodeEditor`.
+- generic HTML `code` is not a `UICodeEditor`.
+- markdown/global opt-in `code` is a `UICodeEditor`.
 - indentation before `return` survives.
 - line count is at least 3.
 - the `
`/`` content participates in normal RichText layout.
@@ -321,7 +381,7 @@ ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tes
 3. Implement all non-`break-spaces` collapse modes.
 4. Add RichText/LineWrap support for `break-spaces`.
 5. Add `discard` parser/state behavior.
-6. Remove the `
` `UICodeEditor` fallback.
+6. Restrict the `
` `UICodeEditor` fallback to markdown/global opt-in.
 7. Update `html-layout-architecture.md` with the final whitespace-processing notes.
 8. Run focused tests, then `UIHTML.*`, then the full suite.
 
@@ -329,7 +389,8 @@ ASAN_OPTIONS=detect_leaks=0 xvfb-run -a -s "-screen 0 1280x1024x24" bin/unit_tes
 
 - All `white-space-collapse` values have parser, property-string, layout, and test coverage.
 - Existing `white-space: nowrap` behavior remains passing.
-- `
` renders through normal HTML/RichText nodes, not `UICodeEditor`.
+- Generic HTML `
` renders through normal HTML/RichText nodes, not `UICodeEditor`.
+- Markdown `
` can still render through read-only `UICodeEditor`.
 - Code indentation and blank lines are preserved in `
` and `white-space: pre` content.
 - Default HTML whitespace still collapses around block and inline boundaries.
 - `break-spaces` wraps and measures trailing spaces according to CSS Text Level 4.
diff --git a/bin/unit_tests/assets/html/pre.code.2.html b/bin/unit_tests/assets/html/pre.code.2.html
new file mode 100644
index 000000000..74de310b5
--- /dev/null
+++ b/bin/unit_tests/assets/html/pre.code.2.html
@@ -0,0 +1,22 @@
+
+  
+    
+  
+  
+    
int x = 42;
+const char* y = "hello";
+int z = foo(x, y);
+
+ + diff --git a/bin/unit_tests/assets/html/pre.code.html b/bin/unit_tests/assets/html/pre.code.html new file mode 100644 index 000000000..7b98c5b2c --- /dev/null +++ b/bin/unit_tests/assets/html/pre.code.html @@ -0,0 +1,6 @@ + +
void main() {
+    printf("Hello World");
+}
+
+ diff --git a/bin/unit_tests/assets/html/pre_code_block.html b/bin/unit_tests/assets/html/pre_code_block.html new file mode 100644 index 000000000..1df972d33 --- /dev/null +++ b/bin/unit_tests/assets/html/pre_code_block.html @@ -0,0 +1,82 @@ + + + + + +
let stepper () =
+    // Execute a single instruction
+    // Each instruction uses a different amount of cycles
+    let mCycles = stepCpu cpu io
+      
+    for _ in 1..mCycles do  
+        stepTimers timer io  
+        stepSerial serial io
+        // The APU technically runs at 4x CPU-cycles, but can be batched
+        stepApu apu  
+      
+    let tCycles = mCycles * 4  
+    
+    // The PPU operates at 4x CPU-cycles. The APU should be here too
+    for _ in 1..tCycles do  
+        stepPpu ppu  
+    
+    // Return cycles taken so the frontend runs the emulator at the right speed
+    mCycles 
+ diff --git a/include/eepp/graphics/richtext.hpp b/include/eepp/graphics/richtext.hpp index 57d63cd31..fef19d037 100644 --- a/include/eepp/graphics/richtext.hpp +++ b/include/eepp/graphics/richtext.hpp @@ -23,6 +23,8 @@ class EE_API RichText : public Drawable { enum class InlineClear { None, Left, Right, Both }; + enum class WhiteSpaceWrapMode { Normal, Preserve, BreakSpaces }; + struct FloatExclusion { Rectf rect; InlineFloat type{ InlineFloat::None }; @@ -95,10 +97,22 @@ class EE_API RichText : public Drawable { */ void addSpan( const String& text, const FontStyleConfig& style ); + void addSpan( const String::View& text, const FontStyleConfig& style ); + + void addSpan( String&& text, const FontStyleConfig& style ); + void addSpan( const String& text, const FontStyleConfig& style, const Rectf& margin, const Rectf& padding, Float lineHeight = 0, const BaselineAlignValue& baselineAlign = {}, InlineSource source = {} ); + void addSpan( const String::View& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight = 0, + const BaselineAlignValue& baselineAlign = {}, InlineSource source = {} ); + + void addSpan( String&& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight = 0, + const BaselineAlignValue& baselineAlign = {}, InlineSource source = {} ); + /** * @brief Adds a text span with individual style parameters. * @param text The text content. @@ -141,6 +155,14 @@ class EE_API RichText : public Drawable { /** @return Whether soft wrapping is enabled. */ bool getLineWrap() const { return mLineWrap; } + void setWhiteSpaceWrapMode( WhiteSpaceWrapMode mode ); + + WhiteSpaceWrapMode getWhiteSpaceWrapMode() const { return mWhiteSpaceWrapMode; } + + void setTabWidth( Uint32 tabWidth ); + + Uint32 getTabWidth() const { return mTabWidth; } + bool setExternalFloatExclusions( const std::vector& exclusions ); const std::vector& getExternalFloatExclusions() const { @@ -402,12 +424,30 @@ class EE_API RichText : public Drawable { const Rectf& padding, Float lineHeight, const BaselineAlignValue& baselineAlign, InlineSource source = {} ); + void addInlineText( const String::View& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source = {} ); + + void addInlineText( String&& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source = {} ); + /** Add an atomic inline-level box to the current inline context. */ void addInlineAtomicBox( const Sizef& size, InlineFloat floatType, InlineClear clearType, Float baseline, bool isLineBreak, const BaselineAlignValue& baselineAlign, InlineSource source = {} ); protected: + template + void addSpanImpl( TextType&& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source ); + + template + void addInlineTextImpl( TextType&& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source ); + void rebuildInlineFragments(); std::vector mInlineItems; @@ -427,6 +467,8 @@ class EE_API RichText : public Drawable { Float mLineHeight{ 0 }; Float mTextIndent{ 0 }; bool mLineWrap{ true }; + WhiteSpaceWrapMode mWhiteSpaceWrapMode{ WhiteSpaceWrapMode::Normal }; + Uint32 mTabWidth{ 8 }; }; }} // namespace EE::Graphics diff --git a/include/eepp/ui/css/propertydefinition.hpp b/include/eepp/ui/css/propertydefinition.hpp index 46fc99994..054ba0ac2 100644 --- a/include/eepp/ui/css/propertydefinition.hpp +++ b/include/eepp/ui/css/propertydefinition.hpp @@ -226,6 +226,7 @@ enum class PropertyId : Uint32 { LineSpacing = String::hash( "line-spacing" ), LineHeight = String::hash( "line-height" ), TextIndent = String::hash( "text-indent" ), + TabSize = String::hash( "tab-size" ), GravityOwner = String::hash( "gravity-owner" ), Href = String::hash( "href" ), Focusable = String::hash( "focusable" ), diff --git a/include/eepp/ui/uirichtext.hpp b/include/eepp/ui/uirichtext.hpp index bca520204..05e81727a 100644 --- a/include/eepp/ui/uirichtext.hpp +++ b/include/eepp/ui/uirichtext.hpp @@ -17,10 +17,11 @@ class EE_API UIRichText : public UIHTMLWidget { Preserve, PreserveBreaks, PreserveSpaces, - BreakSpaces + BreakSpaces, + Discard }; - static WhiteSpaceCollapse toWhiteSpaceCollapse( std::string val ); + static WhiteSpaceCollapse toWhiteSpaceCollapse( std::string_view val ); static std::string fromWhiteSpaceCollapse( WhiteSpaceCollapse val ); @@ -31,6 +32,10 @@ class EE_API UIRichText : public UIHTMLWidget { static void rebuildRichText( UILayout* container, RichText& richText, IntrinsicMode mode = IntrinsicMode::None ); + static void setUseCodeEditorForPreCodeBlocks( bool enabled ); + + static bool getUseCodeEditorForPreCodeBlocks(); + static UIRichText* New(); static UIRichText* NewWithTag( const std::string& tag ); @@ -140,7 +145,7 @@ class EE_API UIRichText : public UIHTMLWidget { void setLineWrap( bool lineWrap ); - void applyWhiteSpace( std::string val ); + void applyWhiteSpace( std::string_view val ); Float getLineHeightPx() const; @@ -150,6 +155,10 @@ class EE_API UIRichText : public UIHTMLWidget { UIRichText* setTextIndentEq( const std::string& eq ); + Uint32 getTabSize() const; + + UIRichText* setTabSize( Uint32 tabSize ); + bool isTextSelectionEnabled() const; void setTextSelectionEnabled( bool active ); @@ -185,6 +194,7 @@ class EE_API UIRichText : public UIHTMLWidget { mutable bool mLineHeightPxDirty{ true }; mutable Float mTextIndentPxCache{ 0 }; mutable bool mTextIndentPxDirty{ true }; + Uint32 mTabSize{ 8 }; WhiteSpaceCollapse mWhiteSpaceCollapse{ WhiteSpaceCollapse::Collapse }; bool mLineWrap{ true }; TextTransform::Value mTextTransform{ TextTransform::None }; diff --git a/src/eepp/graphics/richtext.cpp b/src/eepp/graphics/richtext.cpp index 8893bef7b..c73f68d06 100644 --- a/src/eepp/graphics/richtext.cpp +++ b/src/eepp/graphics/richtext.cpp @@ -474,6 +474,17 @@ static void invalidateInlineItemText( std::vector& items ) } } +static void setInlineItemTextTabWidth( std::vector& items, Uint32 tabWidth ) { + for ( auto& item : items ) { + if ( item.isTextRun() ) { + if ( item.asTextRun().text ) + item.asTextRun().text->setTabWidth( tabWidth ); + } else if ( item.isBox() ) { + setInlineItemTextTabWidth( item.asBox().children, tabWidth ); + } + } +} + struct InlineAncestorRef { RichText::RenderSpan::InlinePath path; const RichText::InlineItem::Box* box{ nullptr }; @@ -717,7 +728,8 @@ class RichTextInlineLayouter { return false; } - static Float getMinIntrinsicWidth( const std::vector& inlineItems ) { + static Float getMinIntrinsicWidth( const std::vector& inlineItems, + RichText::WhiteSpaceWrapMode whiteSpaceWrapMode ) { Float minW = 0; for ( const auto& run : buildLayoutRuns( inlineItems ) ) { const auto& payload = run.payload; @@ -726,27 +738,39 @@ class RichTextInlineLayouter { if ( !span || span->getString().empty() ) continue; const String& s = span->getString(); - size_t start = 0; - size_t end = 0; - while ( start < s.size() ) { - while ( start < s.size() && ( s[start] == ' ' || s[start] == '\t' || - s[start] == '\n' || s[start] == '\r' ) ) - start++; - end = start; - while ( end < s.size() && !( s[end] == ' ' || s[end] == '\t' || - s[end] == '\n' || s[end] == '\r' ) ) - end++; - if ( start < end ) { - minW = std::max( - minW, - Text::getTextWidth( s.substr( start, end - start ), - span->getFontStyleConfig() ) + - payload.margin.Left + payload.margin.Right + payload.padding.Left + + Float spacing = payload.margin.Left + payload.margin.Right + payload.padding.Left + payload.padding.Right + inlineAncestorStartSpacing( inlineItems, payload.inlinePath ) + - inlineAncestorEndSpacing( inlineItems, payload.inlinePath ) ); + inlineAncestorEndSpacing( inlineItems, payload.inlinePath ); + if ( whiteSpaceWrapMode == RichText::WhiteSpaceWrapMode::BreakSpaces ) { + for ( size_t i = 0; i < s.size(); ++i ) { + if ( s[i] == '\n' || s[i] == '\r' ) + continue; + minW = std::max( + minW, Text::getTextWidth( s.substr( i, 1 ), span->getFontStyleConfig(), + span->getTabWidth(), span->getTextHints() ) + + spacing ); + } + } else { + size_t start = 0; + size_t end = 0; + while ( start < s.size() ) { + while ( start < s.size() && ( s[start] == ' ' || s[start] == '\t' || + s[start] == '\n' || s[start] == '\r' ) ) + start++; + end = start; + while ( end < s.size() && !( s[end] == ' ' || s[end] == '\t' || + s[end] == '\n' || s[end] == '\r' ) ) + end++; + if ( start < end ) { + minW = std::max( minW, Text::getTextWidth( + s.substr( start, end - start ), + span->getFontStyleConfig(), + span->getTabWidth(), span->getTextHints() ) + + spacing ); + } + start = end; } - start = end; } } else if ( payload.type == RichText::RenderSpan::Type::Drawable ) { if ( payload.drawable ) @@ -777,17 +801,17 @@ class RichTextInlineLayouter { curX += payload.margin.Left + payload.padding.Left + inlineAncestorStartSpacing( inlineItems, payload.inlinePath ); while ( ( end = s.find( '\n', start ) ) != String::InvalidPos ) { - curX += - Text::getTextWidth( s.substr( start, end - start ), - span->getFontStyleConfig(), 4, span->getTextHints() ); + curX += Text::getTextWidth( s.substr( start, end - start ), + span->getFontStyleConfig(), span->getTabWidth(), + span->getTextHints() ); maxW = std::max( maxW, curX + payload.margin.Right + payload.padding.Right + inlineAncestorEndSpacing( inlineItems, payload.inlinePath ) ); curX = 0; start = end + 1; } - curX += Text::getTextWidth( s.substr( start ), span->getFontStyleConfig(), 4, - span->getTextHints() ) + + curX += Text::getTextWidth( s.substr( start ), span->getFontStyleConfig(), + span->getTabWidth(), span->getTextHints() ) + payload.margin.Right + payload.padding.Right + inlineAncestorEndSpacing( inlineItems, payload.inlinePath ); } else if ( payload.type == RichText::RenderSpan::Type::Drawable ) { @@ -863,7 +887,8 @@ class RichTextInlineLayouter { static LayoutResult layoutNoFloats( const std::vector& inlineItems, Float maxLayoutWidth, Float textIndent, Uint32 align, Float forcedLineHeight, const FontStyleConfig& defaultStyle, - bool lineWrap ) { + bool lineWrap, + RichText::WhiteSpaceWrapMode whiteSpaceWrapMode ) { LayoutResult result; result.lines.push_back( RichText::RenderParagraph() ); @@ -895,14 +920,20 @@ class RichTextInlineLayouter { addInlineSpacingToCurrentLine( result, curX, inlineStartSpacing( payload, inlineItems ) ); - LineWrapInfoEx wrapInfo = computeTextWraps( payload, fontStyle, maxLayoutWidth, - lineWrap && maxLayoutWidth > 0, curX ); + LineWrapInfoEx wrapInfo = + computeTextWraps( payload, fontStyle, maxLayoutWidth, + lineWrap && maxLayoutWidth > 0, curX, whiteSpaceWrapMode ); for ( size_t i = 0; i < wrapInfo.wraps.size() - 1; ++i ) { size_t startIdx = wrapInfo.wraps[i]; size_t endIdx = wrapInfo.wraps[i + 1]; - bool isNewline = - ( endIdx - startIdx == 1 && span->getString()[startIdx] == '\n' ); + bool startsWithNewline = + startIdx < endIdx && span->getString()[startIdx] == '\n'; + bool isNewline = startsWithNewline && endIdx - startIdx == 1; + if ( startsWithNewline && !isNewline ) { + curCharIdx++; + startIdx++; + } if ( !isNewline ) { appendTextRenderSpan( result.lines.back(), payload, fontStyle, startIdx, @@ -994,10 +1025,12 @@ class RichTextInlineLayouter { return result; } - static LayoutResult layoutWithFloats( - const std::vector& inlineItems, Float maxLayoutWidth, - Float textIndent, Uint32 align, Float forcedLineHeight, const FontStyleConfig& defaultStyle, - const std::vector& externalFloatExclusions, bool lineWrap ) { + static LayoutResult + layoutWithFloats( const std::vector& inlineItems, Float maxLayoutWidth, + Float textIndent, Uint32 align, Float forcedLineHeight, + const FontStyleConfig& defaultStyle, + const std::vector& externalFloatExclusions, + bool lineWrap, RichText::WhiteSpaceWrapMode whiteSpaceWrapMode ) { LayoutResult result; result.lines.push_back( RichText::RenderParagraph() ); @@ -1109,14 +1142,19 @@ class RichTextInlineLayouter { if ( maxLayoutWidth > 0 && maxLayoutWidth < effW ) effW = maxLayoutWidth; - LineWrapInfoEx wrapInfo = - computeTextWraps( payload, fontStyle, effW, lineWrap && effW > 0, curX ); + LineWrapInfoEx wrapInfo = computeTextWraps( + payload, fontStyle, effW, lineWrap && effW > 0, curX, whiteSpaceWrapMode ); for ( size_t i = 0; i < wrapInfo.wraps.size() - 1; ++i ) { size_t startIdx = wrapInfo.wraps[i]; size_t endIdx = wrapInfo.wraps[i + 1]; - bool isNewline = - ( endIdx - startIdx == 1 && span->getString()[startIdx] == '\n' ); + bool startsWithNewline = + startIdx < endIdx && span->getString()[startIdx] == '\n'; + bool isNewline = startsWithNewline && endIdx - startIdx == 1; + if ( startsWithNewline && !isNewline ) { + curCharIdx++; + startIdx++; + } if ( !isNewline ) { appendTextRenderSpan( result.lines.back(), payload, fontStyle, startIdx, @@ -1591,13 +1629,113 @@ class RichTextInlineLayouter { result.lines.back().width += spacing; } + static bool isBreakSpacesWrapChar( String::StringBaseType ch ) { + return ch == ' ' || ch == '\t'; + } + + static Float breakSpacesTextWidth( const String& string, size_t start, size_t end, + const FontStyleConfig& fontStyle, Uint32 tabWidth, + Uint32 textHints ) { + if ( end <= start ) + return 0.f; + return Text::getTextWidth( string.substr( start, end - start ), fontStyle, tabWidth, + textHints ); + } + + static LineWrapInfoEx computeBreakSpacesTextWraps( const RichText::RenderSpan& span, + const FontStyleConfig& fontStyle, + Float wrapWidth, bool shouldWrap, + Float curX ) { + LineWrapInfoEx wrapInfo; + const String& string = span.text->getString(); + wrapInfo.wraps.push_back( 0 ); + + if ( string.empty() ) + return wrapInfo; + + const Uint32 tabWidth = span.text->getTabWidth(); + const Uint32 textHints = span.text->getTextHints(); + size_t lineStart = 0; + size_t lastBreakAfter = String::InvalidPos; + Float lineBaseX = curX; + + auto visualStart = [&string]( size_t start ) { + return start < string.size() && string[start] == '\n' ? start + 1 : start; + }; + auto pushWrap = [&wrapInfo]( size_t index, Float width ) { + if ( wrapInfo.wraps.back() != static_cast( index ) ) { + wrapInfo.wrapsWidth.push_back( std::ceil( width ) ); + wrapInfo.wraps.push_back( index ); + } + }; + + for ( size_t idx = 0; idx < string.size(); ++idx ) { + if ( string[idx] == '\n' ) { + pushWrap( idx, + lineBaseX + breakSpacesTextWidth( string, visualStart( lineStart ), idx, + fontStyle, tabWidth, textHints ) ); + lineStart = idx; + lastBreakAfter = String::InvalidPos; + lineBaseX = 0.f; + continue; + } + + size_t start = visualStart( lineStart ); + Float candidateWidth = + lineBaseX + + breakSpacesTextWidth( string, start, idx + 1, fontStyle, tabWidth, textHints ); + + if ( shouldWrap && wrapWidth > 0 && candidateWidth > wrapWidth ) { + if ( lastBreakAfter != String::InvalidPos && lastBreakAfter > lineStart ) { + pushWrap( lastBreakAfter, + lineBaseX + breakSpacesTextWidth( string, start, lastBreakAfter, + fontStyle, tabWidth, textHints ) ); + lineStart = lastBreakAfter; + lastBreakAfter = String::InvalidPos; + lineBaseX = 0.f; + } else if ( isBreakSpacesWrapChar( string[idx] ) ) { + pushWrap( idx + 1, candidateWidth ); + lineStart = idx + 1; + lastBreakAfter = String::InvalidPos; + lineBaseX = 0.f; + continue; + } else if ( idx > start ) { + pushWrap( idx, lineBaseX + breakSpacesTextWidth( string, start, idx, fontStyle, + tabWidth, textHints ) ); + lineStart = idx; + lastBreakAfter = String::InvalidPos; + lineBaseX = 0.f; + } + } + + if ( isBreakSpacesWrapChar( string[idx] ) && lineStart <= idx ) + lastBreakAfter = idx + 1; + } + + if ( !string.empty() && string[string.size() - 1] != '\n' ) { + wrapInfo.wrapsWidth.push_back( + std::ceil( lineBaseX + breakSpacesTextWidth( string, visualStart( lineStart ), + string.size(), fontStyle, tabWidth, + span.text->getTextHints() ) ) ); + } + + if ( wrapInfo.wraps.back() != static_cast( string.size() ) ) + wrapInfo.wraps.push_back( string.size() ); + + return wrapInfo; + } + static LineWrapInfoEx computeTextWraps( const RichText::RenderSpan& span, const FontStyleConfig& fontStyle, Float wrapWidth, - bool shouldWrap, Float curX ) { + bool shouldWrap, Float curX, + RichText::WhiteSpaceWrapMode whiteSpaceWrapMode ) { + if ( whiteSpaceWrapMode == RichText::WhiteSpaceWrapMode::BreakSpaces ) + return computeBreakSpacesTextWraps( span, fontStyle, wrapWidth, shouldWrap, curX ); + LineWrapInfoEx wrapInfo = LineWrap::computeLineBreaksEx( span.text->getString(), fontStyle, wrapWidth > 0 ? wrapWidth : 1e9f, - shouldWrap ? LineWrapMode::Word : LineWrapMode::NoWrap, false, 4, 0.f, - span.text->getTextHints(), false, curX ); + shouldWrap ? LineWrapMode::Word : LineWrapMode::NoWrap, false, span.text->getTabWidth(), + 0.f, span.text->getTextHints(), false, curX ); if ( wrapInfo.wraps.empty() || wrapInfo.wraps.back() != (Float)span.text->getString().size() ) @@ -1656,6 +1794,7 @@ class RichTextInlineLayouter { FontStyleConfig renderStyle = fontStyle; renderStyle.Style |= inlineAncestorTextDecoration( inlineItems, payload.inlinePath ); renderSpanText->setStyleConfig( renderStyle ); + renderSpanText->setTabWidth( payload.text->getTabWidth() ); Float height = getTextRunLineHeight( payload.text, payload.lineHeight ); Float ascent = getTextBaseline( payload.text, payload.lineHeight ); @@ -1864,15 +2003,22 @@ class RichTextInlineLayouter { } // namespace -void RichText::addSpan( const String& text, const FontStyleConfig& style, const Rectf& margin, - const Rectf& padding, Float lineHeight, - const BaselineAlignValue& baselineAlign, InlineSource source ) { +template static void setTextString( Text& text, TextType&& string ) { + text.setString( std::forward( string ) ); +} + +template +void RichText::addSpanImpl( TextType&& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source ) { if ( text.empty() && margin == Rectf::Zero && padding == Rectf::Zero && lineHeight == 0 ) return; + const bool hasText = !text.empty(); auto span = std::make_shared(); - span->setString( text ); + setTextString( *span, std::forward( text ) ); span->setStyleConfig( style ); + span->setTabWidth( mTabWidth ); InlineItem item; InlineItem::Box box; @@ -1882,7 +2028,7 @@ void RichText::addSpan( const String& text, const FontStyleConfig& style, const box.baselineAlign = baselineAlign; box.participatesInLineMetrics = false; box.contributesInlineSpacing = false; - if ( !text.empty() || margin != Rectf::Zero || padding != Rectf::Zero || lineHeight > 0 ) { + if ( hasText || margin != Rectf::Zero || padding != Rectf::Zero || lineHeight > 0 ) { InlineItem textItem; auto& run = textItem.asTextRun(); run.text = span; @@ -1899,6 +2045,24 @@ void RichText::addSpan( const String& text, const FontStyleConfig& style, const invalidateLayout(); } +void RichText::addSpan( const String& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source ) { + addSpanImpl( text, style, margin, padding, lineHeight, baselineAlign, source ); +} + +void RichText::addSpan( const String::View& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source ) { + addSpanImpl( text, style, margin, padding, lineHeight, baselineAlign, source ); +} + +void RichText::addSpan( String&& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source ) { + addSpanImpl( std::move( text ), style, margin, padding, lineHeight, baselineAlign, source ); +} + void RichText::addDrawable( std::shared_ptr drawable ) { if ( !drawable ) return; @@ -1976,17 +2140,19 @@ void RichText::popInlineBox() { mInlinePath.pop_back(); } -void RichText::addInlineText( const String& text, const FontStyleConfig& style, const Rectf& margin, - const Rectf& padding, Float lineHeight, - const BaselineAlignValue& baselineAlign, InlineSource source ) { +template +void RichText::addInlineTextImpl( TextType&& text, const FontStyleConfig& style, + const Rectf& margin, const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source ) { if ( text.empty() && margin == Rectf::Zero && padding == Rectf::Zero && lineHeight == 0 ) return; InlineItem textItem; auto& run = textItem.asTextRun(); run.text = std::make_shared(); - run.text->setString( text ); + setTextString( *run.text, std::forward( text ) ); run.text->setStyleConfig( style ); + run.text->setTabWidth( mTabWidth ); run.source = source; run.margin = margin; run.padding = padding; @@ -1998,6 +2164,25 @@ void RichText::addInlineText( const String& text, const FontStyleConfig& style, invalidateLayout(); } +void RichText::addInlineText( const String& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source ) { + addInlineTextImpl( text, style, margin, padding, lineHeight, baselineAlign, source ); +} + +void RichText::addInlineText( const String::View& text, const FontStyleConfig& style, + const Rectf& margin, const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source ) { + addInlineTextImpl( text, style, margin, padding, lineHeight, baselineAlign, source ); +} + +void RichText::addInlineText( String&& text, const FontStyleConfig& style, const Rectf& margin, + const Rectf& padding, Float lineHeight, + const BaselineAlignValue& baselineAlign, InlineSource source ) { + addInlineTextImpl( std::move( text ), style, margin, padding, lineHeight, baselineAlign, + source ); +} + void RichText::addInlineAtomicBox( const Sizef& size, InlineFloat floatType, InlineClear clearType, Float baseline, bool isLineBreak, const BaselineAlignValue& baselineAlign, InlineSource source ) { @@ -2020,6 +2205,14 @@ void RichText::addSpan( const String& text, const FontStyleConfig& style ) { addSpan( text, style, Rectf::Zero, Rectf::Zero ); } +void RichText::addSpan( const String::View& text, const FontStyleConfig& style ) { + addSpan( text, style, Rectf::Zero, Rectf::Zero ); +} + +void RichText::addSpan( String&& text, const FontStyleConfig& style ) { + addSpan( std::move( text ), style, Rectf::Zero, Rectf::Zero ); +} + void RichText::addSpan( const String& text, Font* font, Uint32 characterSize, Color color, Uint32 style, Color backgroundColor ) { FontStyleConfig config; @@ -2075,6 +2268,22 @@ void RichText::setLineWrap( bool lineWrap ) { } } +void RichText::setWhiteSpaceWrapMode( WhiteSpaceWrapMode mode ) { + if ( mWhiteSpaceWrapMode != mode ) { + mWhiteSpaceWrapMode = mode; + invalidateLayout(); + } +} + +void RichText::setTabWidth( Uint32 tabWidth ) { + tabWidth = eemax( 1, tabWidth ); + if ( mTabWidth != tabWidth ) { + mTabWidth = tabWidth; + setInlineItemTextTabWidth( mInlineItems, mTabWidth ); + invalidateLayout(); + } +} + bool RichText::setExternalFloatExclusions( const std::vector& exclusions ) { if ( mExternalFloatExclusions == exclusions ) return false; @@ -2090,7 +2299,7 @@ void RichText::invalidate() { } Float RichText::getMinIntrinsicWidth() { - return RichTextInlineLayouter::getMinIntrinsicWidth( mInlineItems ); + return RichTextInlineLayouter::getMinIntrinsicWidth( mInlineItems, mWhiteSpaceWrapMode ); } Float RichText::getMaxIntrinsicWidth() { @@ -2106,8 +2315,9 @@ void RichText::updateLayout() { // ─── Inline layouter fast path: no floats or clears ───────────── if ( !hasFloats ) { - auto result = RichTextInlineLayouter::layoutNoFloats( - mInlineItems, mMaxWidth, mTextIndent, mAlign, mLineHeight, mDefaultStyle, mLineWrap ); + auto result = RichTextInlineLayouter::layoutNoFloats( mInlineItems, mMaxWidth, mTextIndent, + mAlign, mLineHeight, mDefaultStyle, + mLineWrap, mWhiteSpaceWrapMode ); mLines = std::move( result.lines ); mSize = result.size; mTotalCharacterCount = result.totalCharacterCount; @@ -2116,9 +2326,9 @@ void RichText::updateLayout() { return; } - auto result = RichTextInlineLayouter::layoutWithFloats( mInlineItems, mMaxWidth, mTextIndent, - mAlign, mLineHeight, mDefaultStyle, - mExternalFloatExclusions, mLineWrap ); + auto result = RichTextInlineLayouter::layoutWithFloats( + mInlineItems, mMaxWidth, mTextIndent, mAlign, mLineHeight, mDefaultStyle, + mExternalFloatExclusions, mLineWrap, mWhiteSpaceWrapMode ); mLines = std::move( result.lines ); mSize = result.size; mTotalCharacterCount = result.totalCharacterCount; diff --git a/src/eepp/ui/css/stylesheetspecification.cpp b/src/eepp/ui/css/stylesheetspecification.cpp index 64e82ce96..184ebf258 100644 --- a/src/eepp/ui/css/stylesheetspecification.cpp +++ b/src/eepp/ui/css/stylesheetspecification.cpp @@ -234,6 +234,7 @@ void StyleSheetSpecification::registerDefaultProperties() { registerProperty( "line-spacing", "", true ).setType( PropertyType::NumberLength ); registerProperty( "line-height", "", true ).setType( PropertyType::NumberLength ); registerProperty( "text-indent", "", true ).setType( PropertyType::NumberLength ); + registerProperty( "tab-size", "8", true ).setType( PropertyType::String ); registerProperty( "text-stroke-width", "", true ) .setType( PropertyType::NumberLength ) .addAlias( "fontoutlinethickness" ); diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index 18895c977..e07e9d41d 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -23,16 +23,73 @@ namespace EE { namespace UI { -UIRichText::WhiteSpaceCollapse UIRichText::toWhiteSpaceCollapse( std::string val ) { - String::toLowerInPlace( val ); - if ( "preserve" == val ) +struct ProcessedText { + String::View view; + String storage; + bool owns{ false }; + + explicit ProcessedText( String::View source ) : view( source ) {} + + bool empty() const { return view.empty(); } + + std::size_t length() const { return view.length(); } + + String::StringBaseType front() const { return view.front(); } + + String::StringBaseType back() const { return view.back(); } + + void setStorage( String&& value ) { + storage = std::move( value ); + view = storage.view(); + owns = true; + } + + void removePrefix( std::size_t count ) { + if ( count == 0 || view.empty() ) + return; + if ( owns ) { + storage.erase( 0, count ); + view = storage.view(); + } else { + view.remove_prefix( count ); + } + } + + void removeSuffix( std::size_t count ) { + if ( count == 0 || view.empty() ) + return; + if ( owns ) { + for ( std::size_t i = 0; i < count && !storage.empty(); ++i ) + storage.pop_back(); + view = storage.view(); + } else { + view.remove_suffix( count ); + } + } +}; + +static bool sUseCodeEditorForPreCodeBlocks = false; + +static bool parseTabSizeCount( const std::string& value, Uint32& out ) { + Uint32 parsed = 0; + if ( !String::fromString( parsed, value ) || parsed == 0 ) + return false; + out = parsed; + return true; +} + +UIRichText::WhiteSpaceCollapse UIRichText::toWhiteSpaceCollapse( std::string_view val ) { + val = String::trim( val ); + if ( String::iequals( val, "preserve" ) ) return WhiteSpaceCollapse::Preserve; - if ( "preserve-breaks" == val || "preserve-breaks" == val ) + if ( String::iequals( val, "preserve-breaks" ) ) return WhiteSpaceCollapse::PreserveBreaks; - if ( "preserve-spaces" == val || "preserve-spaces" == val ) + if ( String::iequals( val, "preserve-spaces" ) ) return WhiteSpaceCollapse::PreserveSpaces; - if ( "break-spaces" == val || "break-spaces" == val ) + if ( String::iequals( val, "break-spaces" ) ) return WhiteSpaceCollapse::BreakSpaces; + if ( String::iequals( val, "discard" ) ) + return WhiteSpaceCollapse::Discard; return WhiteSpaceCollapse::Collapse; } @@ -46,6 +103,8 @@ std::string UIRichText::fromWhiteSpaceCollapse( WhiteSpaceCollapse val ) { return "preserve-spaces"; case WhiteSpaceCollapse::BreakSpaces: return "break-spaces"; + case WhiteSpaceCollapse::Discard: + return "discard"; case WhiteSpaceCollapse::Collapse: default: return "collapse"; @@ -62,12 +121,30 @@ std::string UIRichText::fromWhiteSpace( WhiteSpaceCollapse collapse, bool lineWr return "break-spaces"; case WhiteSpaceCollapse::PreserveSpaces: return lineWrap ? "preserve-spaces" : "preserve nowrap"; + case WhiteSpaceCollapse::Discard: + return lineWrap ? "discard" : "discard nowrap"; case WhiteSpaceCollapse::Collapse: default: return lineWrap ? "normal" : "nowrap"; } } +void UIRichText::setUseCodeEditorForPreCodeBlocks( bool enabled ) { + sUseCodeEditorForPreCodeBlocks = enabled; +} + +bool UIRichText::getUseCodeEditorForPreCodeBlocks() { + return sUseCodeEditorForPreCodeBlocks; +} + +static bool hasMarkdownViewAncestor( const Node* node ) { + for ( const Node* cur = node; cur; cur = cur->getParent() ) { + if ( cur->isType( UI_TYPE_MARKDOWNVIEW ) ) + return true; + } + return false; +} + UIHTMLHtml* UIHTMLHtml::New( const std::string& tag ) { return eeNew( UIHTMLHtml, ( tag ) ); } @@ -272,6 +349,7 @@ UIRichText::UIRichText( const std::string& tag ) : UIHTMLWidget( tag ) { } mRichText.getFontStyleConfig().FontColor = Color::Black; + mRichText.setTabWidth( mTabSize ); setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::WrapContent ); } @@ -394,6 +472,27 @@ bool UIRichText::applyProperty( const StyleSheetProperty& attribute ) { case PropertyId::TextIndent: setTextIndentEq( attribute.value() ); break; + case PropertyId::TabSize: { + Uint32 tabSize = 0; + if ( parseTabSizeCount( attribute.value(), tabSize ) ) { + setTabSize( tabSize ); + } else { + Float tabSizePx = lengthFromValue( attribute ); + const auto& style = mRichText.getFontStyleConfig(); + if ( tabSizePx > 0 && style.Font != nullptr ) { + bool bold = ( style.Style & Text::Style::Bold ) != 0; + bool italic = ( style.Style & Text::Style::Italic ) != 0; + Float spaceWidth = style.Font + ->getGlyph( L' ', style.CharacterSize, bold, italic, + style.OutlineThickness ) + .advance; + if ( spaceWidth > 0 ) + setTabSize( eemax( + 1, static_cast( std::round( tabSizePx / spaceWidth ) ) ) ); + } + } + break; + } case PropertyId::WhiteSpace: applyWhiteSpace( attribute.value() ); break; @@ -453,6 +552,8 @@ std::string UIRichText::getPropertyString( const PropertyDefinition* propertyDef return mLineHeightEq.empty() ? "normal" : mLineHeightEq; case PropertyId::TextIndent: return mTextIndentEq.empty() ? "0" : mTextIndentEq; + case PropertyId::TabSize: + return String::toString( getTabSize() ); case PropertyId::WhiteSpace: return fromWhiteSpace( mWhiteSpaceCollapse, mLineWrap ); case PropertyId::WhiteSpaceCollapse: @@ -472,7 +573,8 @@ std::vector UIRichText::getPropertiesImplemented() const { PropertyId::TextStrokeWidth, PropertyId::TextStrokeColor, PropertyId::TextAlign, PropertyId::SelectionColor, PropertyId::SelectionBackColor, PropertyId::TextSelection, PropertyId::TextDecoration, PropertyId::LineHeight, PropertyId::TextIndent, - PropertyId::WhiteSpace, PropertyId::WhiteSpaceCollapse, PropertyId::TextTransform }; + PropertyId::TabSize, PropertyId::WhiteSpace, PropertyId::WhiteSpaceCollapse, + PropertyId::TextTransform }; props.insert( props.end(), local.begin(), local.end() ); return props; } @@ -700,27 +802,70 @@ void UIRichText::setLineWrap( bool lineWrap ) { } } -void UIRichText::applyWhiteSpace( std::string val ) { - String::toLowerInPlace( val ); - String::trimInPlace( val ); - if ( val == "normal" ) { +void UIRichText::applyWhiteSpace( std::string_view val ) { + val = String::trim( val ); + if ( val.empty() ) { + return; + } + if ( String::iequals( val, "normal" ) ) { setWhiteSpaceCollapse( WhiteSpaceCollapse::Collapse ); setLineWrap( true ); - } else if ( val == "nowrap" ) { + } else if ( String::iequals( val, "nowrap" ) ) { setWhiteSpaceCollapse( WhiteSpaceCollapse::Collapse ); setLineWrap( false ); - } else if ( val == "pre" ) { + } else if ( String::iequals( val, "pre" ) ) { setWhiteSpaceCollapse( WhiteSpaceCollapse::Preserve ); setLineWrap( false ); - } else if ( val == "pre-wrap" ) { + } else if ( String::iequals( val, "pre-wrap" ) ) { setWhiteSpaceCollapse( WhiteSpaceCollapse::Preserve ); setLineWrap( true ); - } else if ( val == "pre-line" ) { + } else if ( String::iequals( val, "pre-line" ) ) { setWhiteSpaceCollapse( WhiteSpaceCollapse::PreserveBreaks ); setLineWrap( true ); - } else if ( val == "break-spaces" ) { + } else if ( String::iequals( val, "break-spaces" ) ) { setWhiteSpaceCollapse( WhiteSpaceCollapse::BreakSpaces ); setLineWrap( true ); + } else if ( String::iequals( val, "preserve-spaces" ) ) { + setWhiteSpaceCollapse( WhiteSpaceCollapse::PreserveSpaces ); + setLineWrap( true ); + } else if ( String::iequals( val, "preserve-breaks" ) ) { + setWhiteSpaceCollapse( WhiteSpaceCollapse::PreserveBreaks ); + setLineWrap( true ); + } else if ( String::iequals( val, "discard" ) ) { + setWhiteSpaceCollapse( WhiteSpaceCollapse::Discard ); + setLineWrap( true ); + } else { + bool foundCollapse = false; + bool foundWrap = false; + WhiteSpaceCollapse collapse = mWhiteSpaceCollapse; + bool lineWrap = mLineWrap; + String::readBySeparator( + val, + [&]( std::string_view token ) { + token = String::trim( token ); + if ( !token.empty() ) { + if ( String::iequals( token, "wrap" ) ) { + lineWrap = true; + foundWrap = true; + } else if ( String::iequals( token, "nowrap" ) ) { + lineWrap = false; + foundWrap = true; + } else if ( String::iequals( token, "collapse" ) || + String::iequals( token, "preserve" ) || + String::iequals( token, "preserve-breaks" ) || + String::iequals( token, "preserve-spaces" ) || + String::iequals( token, "break-spaces" ) || + String::iequals( token, "discard" ) ) { + collapse = toWhiteSpaceCollapse( token ); + foundCollapse = true; + } + } + }, + ' ' ); + if ( foundCollapse ) + setWhiteSpaceCollapse( collapse ); + if ( foundWrap ) + setLineWrap( lineWrap ); } } @@ -793,6 +938,21 @@ Float UIRichText::getTextIndentPx() const { return mTextIndentPxCache; } +Uint32 UIRichText::getTabSize() const { + return mTabSize; +} + +UIRichText* UIRichText::setTabSize( Uint32 tabSize ) { + tabSize = eemax( 1, tabSize ); + if ( mTabSize != tabSize ) { + mTabSize = tabSize; + mRichText.setTabWidth( mTabSize ); + notifyLayoutAttrChange(); + notifyLayoutAttrChangeParent(); + } + return this; +} + const TextTransform::Value& UIRichText::getTextTransform() const { return mTextTransform; } @@ -812,8 +972,9 @@ void UIRichText::loadFromXmlNode( const pugi::xml_node& node ) { for ( pugi::xml_node child = node.first_child(); child; child = child.next_sibling() ) { if ( child.type() == pugi::node_element ) { - if ( mTag == "pre" && String::iequals( child.name(), "code" ) ) { - // Use a UICodeEditor for

+			if ( mTag == "pre" && String::iequals( child.name(), "code" ) &&
+				 ( sUseCodeEditorForPreCodeBlocks || hasMarkdownViewAncestor( this ) ) ) {
+				// Use a UICodeEditor for markdown 
 blocks, or when explicitly enabled.
 				UICodeEditor* editor = UICodeEditor::NewWithTag( "code" );
 				if ( editor ) {
 					editor->setParent( this );
@@ -835,6 +996,8 @@ void UIRichText::loadFromXmlNode( const pugi::xml_node& node ) {
 					if ( langIt != mDataProperties.end() ) {
 						editor->applyProperty( langIt->second );
 					}
+					editor->getUIStyle()->applyInheritedProperties();
+					editor->onWidgetCreated();
 				}
 			} else if ( String::iequals( child.name(), "style" ) ) {
 				CSS::StyleSheetParser parser;
@@ -879,10 +1042,9 @@ void UIRichText::loadFromXmlNode( const pugi::xml_node& node ) {
 				}
 			}
 		} else if ( child.type() == pugi::node_pcdata ) {
-			String collapsed = UIRichText::collapseInternalWhitespace( child.value() );
 			UITextNode* textNode = UITextNode::New();
 			textNode->setParent( this );
-			textNode->setText( collapsed );
+			textNode->setText( child.value() );
 		}
 	}
 
@@ -950,6 +1112,294 @@ String UIRichText::UIRichText::collapseInternalWhitespace( const String& s ) {
 	return out;
 }
 
+static bool isCSSCollapsibleWhiteSpace( const String::StringBaseType& c ) {
+	return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f';
+}
+
+static bool isCSSSegmentBreak( const String::StringBaseType& c ) {
+	return c == '\n' || c == '\r';
+}
+
+static bool isSingleForcedLineBreak( String::View text ) {
+	return text.size() == 1 && text[0] == '\n';
+}
+
+static bool shouldCollapseAcrossTextRuns( UIRichText::WhiteSpaceCollapse collapse ) {
+	return collapse == UIRichText::WhiteSpaceCollapse::Collapse ||
+		   collapse == UIRichText::WhiteSpaceCollapse::PreserveBreaks;
+}
+
+static bool shouldTrimBlockBoundarySpace( UIRichText::WhiteSpaceCollapse collapse ) {
+	return collapse == UIRichText::WhiteSpaceCollapse::Collapse ||
+		   collapse == UIRichText::WhiteSpaceCollapse::PreserveBreaks ||
+		   collapse == UIRichText::WhiteSpaceCollapse::Discard;
+}
+
+static bool trimFinalSegmentBreakBeforeEndTag( ProcessedText& text ) {
+	if ( text.empty() )
+		return false;
+
+	if ( text.back() == '\n' ) {
+		std::size_t count = 1;
+		if ( text.length() > 1 && text.view[text.length() - 2] == '\r' )
+			count++;
+		text.removeSuffix( count );
+		return true;
+	}
+
+	if ( text.back() == '\r' ) {
+		text.removeSuffix( 1 );
+		return true;
+	}
+
+	return false;
+}
+
+static bool containsCarriageReturn( String::View text ) {
+	for ( auto c : text ) {
+		if ( c == '\r' )
+			return true;
+	}
+	return false;
+}
+
+static bool preserveSpacesNeedsTransform( String::View text ) {
+	for ( auto c : text ) {
+		if ( isCSSCollapsibleWhiteSpace( c ) && c != ' ' )
+			return true;
+	}
+	return false;
+}
+
+static bool preserveBreaksNeedsTransform( String::View text ) {
+	bool inSpace = false;
+	for ( std::size_t i = 0; i < text.size(); ++i ) {
+		auto c = text[i];
+		if ( isCSSSegmentBreak( c ) ) {
+			if ( c == '\r' )
+				return true;
+			inSpace = false;
+		} else if ( isCSSCollapsibleWhiteSpace( c ) ) {
+			if ( c != ' ' || inSpace )
+				return true;
+			inSpace = true;
+		} else {
+			inSpace = false;
+		}
+	}
+	return false;
+}
+
+static bool collapseNeedsTransform( String::View text ) {
+	bool inSpace = false;
+	for ( auto c : text ) {
+		if ( isCSSCollapsibleWhiteSpace( c ) ) {
+			if ( c != ' ' || inSpace )
+				return true;
+			inSpace = true;
+		} else {
+			inSpace = false;
+		}
+	}
+	return false;
+}
+
+static bool discardNeedsTransform( String::View text ) {
+	for ( auto c : text ) {
+		if ( isCSSCollapsibleWhiteSpace( c ) )
+			return true;
+	}
+	return false;
+}
+
+static ProcessedText processWhiteSpaceForLayout( String::View source,
+												 UIRichText::WhiteSpaceCollapse collapse,
+												 bool prevIsInline, bool nextIsInline,
+												 bool lastSpanEndsWithSpace,
+												 bool discardSegmentBreakBeforeEndTag ) {
+	ProcessedText text( source );
+	if ( text.empty() )
+		return text;
+
+	if ( discardSegmentBreakBeforeEndTag )
+		trimFinalSegmentBreakBeforeEndTag( text );
+
+	if ( text.empty() )
+		return text;
+
+	String out;
+	bool needsStorage = false;
+
+	switch ( collapse ) {
+		case UIRichText::WhiteSpaceCollapse::Preserve:
+		case UIRichText::WhiteSpaceCollapse::BreakSpaces:
+			needsStorage = containsCarriageReturn( text.view );
+			if ( needsStorage ) {
+				out.reserve( text.length() );
+				for ( std::size_t i = 0; i < text.length(); ++i ) {
+					if ( text.view[i] == '\r' ) {
+						out += '\n';
+						if ( i + 1 < text.length() && text.view[i + 1] == '\n' )
+							i++;
+					} else {
+						out += text.view[i];
+					}
+				}
+			}
+			break;
+		case UIRichText::WhiteSpaceCollapse::PreserveSpaces:
+			needsStorage = preserveSpacesNeedsTransform( text.view );
+			if ( needsStorage ) {
+				out.reserve( text.length() );
+				for ( std::size_t i = 0; i < text.length(); ++i ) {
+					if ( isCSSSegmentBreak( text.view[i] ) ) {
+						out += ' ';
+						if ( text.view[i] == '\r' && i + 1 < text.length() &&
+							 text.view[i + 1] == '\n' )
+							i++;
+					} else {
+						out += isCSSCollapsibleWhiteSpace( text.view[i] ) ? ' ' : text.view[i];
+					}
+				}
+			}
+			break;
+		case UIRichText::WhiteSpaceCollapse::PreserveBreaks:
+			needsStorage = preserveBreaksNeedsTransform( text.view );
+			if ( needsStorage ) {
+				out.reserve( text.length() );
+				bool inSpace = false;
+				for ( std::size_t i = 0; i < text.length(); ++i ) {
+					if ( isCSSSegmentBreak( text.view[i] ) ) {
+						out += '\n';
+						inSpace = false;
+						if ( text.view[i] == '\r' && i + 1 < text.length() &&
+							 text.view[i + 1] == '\n' )
+							i++;
+					} else if ( isCSSCollapsibleWhiteSpace( text.view[i] ) ) {
+						if ( !inSpace ) {
+							out += ' ';
+							inSpace = true;
+						}
+					} else {
+						out += text.view[i];
+						inSpace = false;
+					}
+				}
+			}
+			break;
+		case UIRichText::WhiteSpaceCollapse::Discard:
+			needsStorage = discardNeedsTransform( text.view );
+			if ( needsStorage ) {
+				out.reserve( text.length() );
+				for ( auto c : text.view ) {
+					if ( !isCSSCollapsibleWhiteSpace( c ) )
+						out += c;
+				}
+			}
+			break;
+		case UIRichText::WhiteSpaceCollapse::Collapse:
+		default:
+			needsStorage = collapseNeedsTransform( text.view );
+			if ( needsStorage ) {
+				out.reserve( text.length() );
+				bool inSpace = false;
+				for ( auto c : text.view ) {
+					if ( isCSSCollapsibleWhiteSpace( c ) ) {
+						if ( !inSpace ) {
+							out += ' ';
+							inSpace = true;
+						}
+					} else {
+						out += c;
+						inSpace = false;
+					}
+				}
+			}
+			break;
+	}
+
+	if ( needsStorage )
+		text.setStorage( std::move( out ) );
+
+	if ( shouldTrimBlockBoundarySpace( collapse ) ) {
+		if ( !prevIsInline && !text.empty() && text.front() == ' ' )
+			text.removePrefix( 1 );
+		if ( !nextIsInline && !text.empty() && text.back() == ' ' )
+			text.removeSuffix( 1 );
+		if ( lastSpanEndsWithSpace && !text.empty() && text.front() == ' ' )
+			text.removePrefix( 1 );
+	}
+
+	return text;
+}
+
+static void applyTextTransformValue( String& text, TextTransform::Value transform ) {
+	switch ( transform ) {
+		case TextTransform::LowerCase:
+			text = text.toLower();
+			break;
+		case TextTransform::UpperCase:
+			text = text.toUpper();
+			break;
+		case TextTransform::Capitalize:
+			text = text.capitalize();
+			break;
+		default:
+			break;
+	}
+}
+
+static String materializeTransformedText( String::View text, TextTransform::Value transform ) {
+	String transformed( text );
+	if ( transform != TextTransform::None )
+		applyTextTransformValue( transformed, transform );
+	return transformed;
+}
+
+static void addProcessedTextSpan( RichText& richText, ProcessedText&& text,
+								  const FontStyleConfig& style, bool inlineText,
+								  RichText::InlineSource source = {} ) {
+	if ( text.owns ) {
+		if ( inlineText )
+			richText.addInlineText( std::move( text.storage ), style, Rectf::Zero, Rectf::Zero, 0,
+									{}, source );
+		else
+			richText.addSpan( std::move( text.storage ), style, Rectf::Zero, Rectf::Zero, 0, {},
+							  source );
+		return;
+	}
+
+	if ( inlineText )
+		richText.addInlineText( text.view, style, Rectf::Zero, Rectf::Zero, 0, {}, source );
+	else
+		richText.addSpan( text.view, style, Rectf::Zero, Rectf::Zero, 0, {}, source );
+}
+
+static UIRichText::WhiteSpaceCollapse getEffectiveWhiteSpaceCollapse( Node* node,
+																	  UIRichText* fallback ) {
+	for ( Node* cur = node; cur; cur = cur->getParent() ) {
+		if ( cur->isType( UI_TYPE_TEXTSPAN ) || cur->isType( UI_TYPE_RICHTEXT ) )
+			return cur->asType()->getWhiteSpaceCollapse();
+	}
+	return fallback ? fallback->getWhiteSpaceCollapse() : UIRichText::WhiteSpaceCollapse::Collapse;
+}
+
+static RichText::WhiteSpaceWrapMode
+toRichTextWhiteSpaceWrapMode( UIRichText::WhiteSpaceCollapse collapse ) {
+	switch ( collapse ) {
+		case UIRichText::WhiteSpaceCollapse::BreakSpaces:
+			return RichText::WhiteSpaceWrapMode::BreakSpaces;
+		case UIRichText::WhiteSpaceCollapse::Preserve:
+		case UIRichText::WhiteSpaceCollapse::PreserveSpaces:
+			return RichText::WhiteSpaceWrapMode::Preserve;
+		case UIRichText::WhiteSpaceCollapse::Collapse:
+		case UIRichText::WhiteSpaceCollapse::PreserveBreaks:
+		case UIRichText::WhiteSpaceCollapse::Discard:
+		default:
+			return RichText::WhiteSpaceWrapMode::Normal;
+	}
+}
+
 static Float getAtomicInlineBoxBaseline( UIWidget* widget, const Sizef& widgetSize,
 										 const Rectf& margin ) {
 	Float fallbackBaseline = widgetSize.getHeight() + margin.Top + margin.Bottom;
@@ -1115,16 +1565,19 @@ static Drawable* getInlineBorderDrawable( UIWidget* widget ) {
 
 void UIRichText::rebuildRichText( UILayout* container, RichText& richText, IntrinsicMode mode ) {
 	richText.clear();
-	if ( container->isType( UI_TYPE_RICHTEXT ) ) {
+	if ( container->isType( UI_TYPE_RICHTEXT ) || container->isType( UI_TYPE_TEXTSPAN ) ) {
 		auto* uiRt = static_cast( container );
 		richText.setLineHeight( uiRt->getLineHeightPx() );
 		richText.setTextIndent( uiRt->getTextIndentPx() );
 		richText.setLineWrap( uiRt->getLineWrap() );
+		richText.setTabWidth( uiRt->getTabSize() );
+		richText.setWhiteSpaceWrapMode(
+			toRichTextWhiteSpaceWrapMode( uiRt->getWhiteSpaceCollapse() ) );
 	}
-	bool shouldCollapse = container->isType( UI_TYPE_RICHTEXT )
-							  ? static_cast( container )->getWhiteSpaceCollapse() ==
-									WhiteSpaceCollapse::Collapse
-							  : true;
+	UIRichText* containerRichText =
+		container->isType( UI_TYPE_RICHTEXT ) || container->isType( UI_TYPE_TEXTSPAN )
+			? container->asType()
+			: nullptr;
 	bool lastSpanEndsWithSpace = false;
 	Float maxWidth = 0;
 	bool isInlineBlockTextSpan =
@@ -1176,22 +1629,6 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
 		return TextTransform::None;
 	};
 
-	auto applyTextTransform = []( String& text, TextTransform::Value tt ) {
-		switch ( tt ) {
-			case TextTransform::LowerCase:
-				text = text.toLower();
-				break;
-			case TextTransform::UpperCase:
-				text = text.toUpper();
-				break;
-			case TextTransform::Capitalize:
-				text = text.capitalize();
-				break;
-			default:
-				break;
-		}
-	};
-
 	if ( container->isType( UI_TYPE_TEXTSPAN ) ) {
 		UITextSpan* selfSpan = container->asType();
 		Node* parentNode = container->getParent();
@@ -1199,19 +1636,23 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
 							parentNode->asType()->isFlex();
 		if ( !selfSpan->getText().empty() && ( !selfSpan->isInline() || parentIsFlex ) &&
 			 NULL != selfSpan->getFontStyleConfig().Font ) {
-			String::View selfText = selfSpan->getText().view();
-			String transformed;
+			auto collapse = getEffectiveWhiteSpaceCollapse( selfSpan, containerRichText );
+			richText.setWhiteSpaceWrapMode( toRichTextWhiteSpaceWrapMode( collapse ) );
+			auto selfText = processWhiteSpaceForLayout( selfSpan->getText().view(), collapse, true,
+														true, lastSpanEndsWithSpace,
+														selfSpan->getFirstChild() == nullptr );
 			auto tt = getEffectiveTextTransform( selfSpan );
-			if ( tt != TextTransform::None ) {
-				transformed = selfText;
-				applyTextTransform( transformed, tt );
-				selfText = transformed.view();
-			}
 			FontStyleConfig style = selfSpan->getFontStyleConfig();
 			style.BackgroundColor = Color::Transparent;
-			richText.addSpan( selfText, style, Rectf::Zero, Rectf::Zero, 0, {} );
-			if ( shouldCollapse )
-				lastSpanEndsWithSpace = !selfText.empty() && selfText.back() == ' ';
+			bool endsWithSpace = shouldCollapseAcrossTextRuns( collapse ) && !selfText.empty() &&
+								 selfText.back() == ' ';
+			if ( tt != TextTransform::None )
+				richText.addSpan( materializeTransformedText( selfText.view, tt ), style,
+								  Rectf::Zero, Rectf::Zero, 0, {} );
+			else
+				addProcessedTextSpan( richText, std::move( selfText ), style, false );
+			if ( shouldCollapseAcrossTextRuns( collapse ) )
+				lastSpanEndsWithSpace = endsWithSpace;
 		}
 	}
 
@@ -1255,7 +1696,6 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
 
 		if ( node->isTextNode() ) {
 			UITextNode* textNode = node->asType();
-			String text = textNode->getText();
 
 			Node* prev = findLogicalPrev( node );
 			bool prevIsInline =
@@ -1271,28 +1711,33 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
 				return htmlWidget->getCSSFloat() != CSSFloat::None || htmlWidget->isOutOfFlow();
 			};
 
-			if ( shouldCollapse && textNode->isWhitespaceOnly() &&
+			auto collapse =
+				getEffectiveWhiteSpaceCollapse( textNode->getParent(), containerRichText );
+			richText.setWhiteSpaceWrapMode( toRichTextWhiteSpaceWrapMode( collapse ) );
+
+			if ( collapse == WhiteSpaceCollapse::Collapse && textNode->isWhitespaceOnly() &&
 				 ( !prevIsInline || isFloatingOrOutOfFlow( prev ) ) &&
 				 ( !nextIsInline || isFloatingOrOutOfFlow( next ) ) ) {
 				textNode->setLayoutCharCount( 0 );
 				return;
 			}
 
-			// Strip leading space if prev is not inline (block boundary)
-			if ( !prevIsInline && !text.empty() && text[0] == ' ' )
-				text = text.substr( 1 );
+			auto text = processWhiteSpaceForLayout(
+				textNode->getText().view(), collapse, prevIsInline, nextIsInline,
+				lastSpanEndsWithSpace, textNode->getNextNode() == nullptr );
+			auto tt = getEffectiveTextTransform( textNode );
+			String transformed;
+			if ( tt != TextTransform::None ) {
+				transformed = materializeTransformedText( text.view, tt );
+				text.view = transformed.view();
+				text.owns = false;
+			}
 
-			// Strip trailing space if next is not inline
-			if ( !nextIsInline && !text.empty() && text.back() == ' ' )
-				text = text.substr( 0, text.size() - 1 );
-
-			if ( shouldCollapse && lastSpanEndsWithSpace && !text.empty() && text[0] == ' ' )
-				text = text.substr( 1 );
-
-			{
-				auto tt = getEffectiveTextTransform( textNode );
-				if ( tt != TextTransform::None )
-					applyTextTransform( text, tt );
+			if ( isSingleForcedLineBreak( text.view ) ) {
+				textNode->setLayoutCharCount( text.length() );
+				richText.addLineBreak();
+				lastSpanEndsWithSpace = false;
+				return;
 			}
 
 			if ( text.empty() ) {
@@ -1302,7 +1747,7 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
 
 			textNode->setLayoutCharCount( text.length() );
 
-			if ( shouldCollapse )
+			if ( shouldCollapseAcrossTextRuns( collapse ) )
 				lastSpanEndsWithSpace = text.back() == ' ';
 
 			FontStyleConfig style;
@@ -1313,12 +1758,21 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
 			} else {
 				style = richText.getFontStyleConfig();
 			}
-			if ( inlineBoxDepth > 0 )
-				richText.addInlineText( text, style, Rectf::Zero, Rectf::Zero, 0, {},
-										toRichTextTextSource( textNode ) );
-			else
-				richText.addSpan( text, style, Rectf::Zero, Rectf::Zero, 0, {},
-								  toRichTextTextSource( textNode ) );
+			if ( inlineBoxDepth > 0 ) {
+				if ( tt != TextTransform::None )
+					richText.addInlineText( std::move( transformed ), style, Rectf::Zero,
+											Rectf::Zero, 0, {}, toRichTextTextSource( textNode ) );
+				else
+					addProcessedTextSpan( richText, std::move( text ), style, true,
+										  toRichTextTextSource( textNode ) );
+			} else {
+				if ( tt != TextTransform::None )
+					richText.addSpan( std::move( transformed ), style, Rectf::Zero, Rectf::Zero, 0,
+									  {}, toRichTextTextSource( textNode ) );
+				else
+					addProcessedTextSpan( richText, std::move( text ), style, false,
+										  toRichTextTextSource( textNode ) );
+			}
 			return;
 		}
 
@@ -1372,8 +1826,6 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
 			inlineBoxDepth++;
 
 			if ( hasOwnText ) {
-				String::View spanText = span->getText().view();
-
 				Node* prev = findLogicalPrev( node );
 				bool prevIsInline =
 					prev && prev->isWidget() && prev->asType()->isInlineDisplay();
@@ -1382,29 +1834,26 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
 				bool nextIsInline =
 					next && next->isWidget() && next->asType()->isInlineDisplay();
 
-				if ( !prevIsInline && !spanText.empty() && spanText[0] == ' ' )
-					spanText = spanText.substr( 1 );
-				if ( !nextIsInline && !spanText.empty() && spanText.back() == ' ' )
-					spanText = spanText.substr( 0, spanText.size() - 1 );
-
-				if ( shouldCollapse && lastSpanEndsWithSpace && !spanText.empty() &&
-					 spanText[0] == ' ' )
-					spanText = spanText.substr( 1 );
-
-				String transformed;
+				auto collapse = getEffectiveWhiteSpaceCollapse( span, containerRichText );
+				richText.setWhiteSpaceWrapMode( toRichTextWhiteSpaceWrapMode( collapse ) );
+				auto spanText = processWhiteSpaceForLayout(
+					span->getText().view(), collapse, prevIsInline, nextIsInline,
+					lastSpanEndsWithSpace, span->getFirstChild() == nullptr );
 				auto tt = getEffectiveTextTransform( span );
 				if ( tt != TextTransform::None ) {
-					transformed = spanText;
-					applyTextTransform( transformed, tt );
-					spanText = transformed.view();
+					String transformed = materializeTransformedText( spanText.view, tt );
+					spanText.setStorage( std::move( transformed ) );
 				}
 
 				if ( !spanText.empty() ) {
-					richText.addInlineText( spanText, span->getFontStyleConfig(), Rectf::Zero,
-											Rectf::Zero, 0, {} );
-					span->setLayoutCharCount( spanText.length() );
-					if ( shouldCollapse )
-						lastSpanEndsWithSpace = spanText.back() == ' ';
+					auto spanTextLength = spanText.length();
+					bool endsWithSpace = shouldCollapseAcrossTextRuns( collapse ) &&
+										 !spanText.empty() && spanText.back() == ' ';
+					addProcessedTextSpan( richText, std::move( spanText ),
+										  span->getFontStyleConfig(), true );
+					span->setLayoutCharCount( spanTextLength );
+					if ( shouldCollapseAcrossTextRuns( collapse ) )
+						lastSpanEndsWithSpace = endsWithSpace;
 				} else {
 					span->setLayoutCharCount( 0 );
 				}
@@ -1419,7 +1868,9 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
 				spanChild = spanChild->getNextNode();
 			}
 
-			if ( shouldCollapse && span->isInlineBlock() )
+			if ( shouldCollapseAcrossTextRuns(
+					 getEffectiveWhiteSpaceCollapse( span, containerRichText ) ) &&
+				 span->isInlineBlock() )
 				lastSpanEndsWithSpace = true;
 
 			inlineBoxDepth--;
diff --git a/src/eepp/ui/uitextspan.cpp b/src/eepp/ui/uitextspan.cpp
index 1a9e87511..456e32809 100644
--- a/src/eepp/ui/uitextspan.cpp
+++ b/src/eepp/ui/uitextspan.cpp
@@ -75,9 +75,8 @@ void UITextSpan::draw() {
 	// flex container (blockification per CSS Flexbox §4). In that case the
 	// parent flex container does NOT render its text via rebuildRichText(),
 	// so the span must draw itself.
-	if ( !isInline() ||
-		 ( getParent() && getParent()->isType( UI_TYPE_HTML_WIDGET ) &&
-		   getParent()->asType()->isFlex() ) ) {
+	if ( !isInline() || ( getParent() && getParent()->isType( UI_TYPE_HTML_WIDGET ) &&
+						  getParent()->asType()->isFlex() ) ) {
 		UIRichText::draw();
 	}
 }
@@ -486,16 +485,15 @@ void UITextSpan::loadFromXmlNode( const pugi::xml_node& node ) {
 					widget->loadFromXmlNode( child );
 				}
 			} else if ( child.type() == pugi::node_pcdata ) {
-				String collapsed = UIRichText::collapseInternalWhitespace( child.value() );
 				UITextNode* textNode = UITextNode::New();
 				textNode->setParent( this );
-				textNode->setText( collapsed );
+				textNode->setText( child.value() );
 			}
 		}
 	} else {
 		for ( pugi::xml_node child = node.first_child(); child; child = child.next_sibling() ) {
 			if ( child.type() == pugi::node_pcdata ) {
-				mText += UIRichText::collapseInternalWhitespace( child.value() );
+				mText += child.value();
 			}
 		}
 	}
diff --git a/src/eepp/ui/uiwidgetcreator.cpp b/src/eepp/ui/uiwidgetcreator.cpp
index d754be63a..2df3405c3 100644
--- a/src/eepp/ui/uiwidgetcreator.cpp
+++ b/src/eepp/ui/uiwidgetcreator.cpp
@@ -79,7 +79,7 @@ h5 { font-size: 0.83em; font-weight: bold; margin: 1.67em 0; }
 h6 { font-size: 0.67em; font-weight: bold; margin: 2.33em 0; }
 
 p { margin: 1em 0; }
-pre { margin: 1em 0; }
+pre { margin: 1em 0; white-space: pre; }
 blockquote { margin: 1em 0; }
 hr { margin: 0.5em 0; background-color: gray; height: 1dp; }
 ul, ol, dl { margin: 1em 0; }
diff --git a/src/tests/unit_tests/richtext_tests.cpp b/src/tests/unit_tests/richtext_tests.cpp
index 10c4d236f..5c87b1042 100644
--- a/src/tests/unit_tests/richtext_tests.cpp
+++ b/src/tests/unit_tests/richtext_tests.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -56,6 +57,36 @@ static void destroyRichTextScene( UI::UISceneNode* sceneNode ) {
 	Engine::destroySingleton();
 }
 
+static String richTextRenderedText( const RichText& richText ) {
+	String text;
+	const auto& lines = richText.getLines();
+	for ( size_t lineIndex = 0; lineIndex < lines.size(); ++lineIndex ) {
+		if ( lineIndex > 0 )
+			text += '\n';
+		for ( const auto& span : lines[lineIndex].spans ) {
+			if ( span.type == RichText::RenderSpan::Type::Text && span.text )
+				text += span.text->getString();
+		}
+	}
+	return text;
+}
+
+static String richTextLineText( const RichText& richText, size_t lineIndex ) {
+	String text;
+	const auto& lines = richText.getLines();
+	if ( lineIndex >= lines.size() )
+		return text;
+	for ( const auto& span : lines[lineIndex].spans ) {
+		if ( span.type == RichText::RenderSpan::Type::Text && span.text )
+			text += span.text->getString();
+	}
+	while ( !text.empty() && ( text.front() == '\n' || text.front() == '\r' ) )
+		text = text.substr( 1 );
+	while ( !text.empty() && ( text.back() == '\n' || text.back() == '\r' ) )
+		text.pop_back();
+	return text;
+}
+
 UTEST( RichText, basicFunctionality ) {
 	Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Test",
 													  WindowStyle::Default, WindowBackend::Default,
@@ -1576,6 +1607,183 @@ UTEST( UIRichText, WhitespaceCollapseCodeTest ) {
 	destroyRichTextScene( sceneNode );
 }
 
+UTEST( UIRichText, WhiteSpaceCollapsePreserveSpacesAndBreaks ) {
+	auto sceneNode = createRichTextScene();
+	ASSERT_TRUE( sceneNode != nullptr );
+
+	String xml = R"xml(
+		alpha  beta
+    gamma
+	)xml";
+
+	sceneNode->loadLayoutFromString( xml );
+	UI::UIRichText* rt = sceneNode->find( "rt" );
+	ASSERT_TRUE( rt != nullptr );
+
+	sceneNode->update( Time::Zero );
+
+	const auto& richText = *rt->getRichTextPtr();
+	EXPECT_EQ( richText.getLines().size(), (size_t)2 );
+	EXPECT_STRINGEQ( richTextLineText( richText, 0 ), "alpha  beta" );
+	EXPECT_STRINGEQ( richTextLineText( richText, 1 ), "    gamma" );
+
+	destroyRichTextScene( sceneNode );
+}
+
+UTEST( UIRichText, WhiteSpaceCollapsePreserveBreaks ) {
+	auto sceneNode = createRichTextScene();
+	ASSERT_TRUE( sceneNode != nullptr );
+
+	String xml = R"xml(
+		alpha   beta
+    gamma
+	)xml";
+
+	sceneNode->loadLayoutFromString( xml );
+	UI::UIRichText* rt = sceneNode->find( "rt" );
+	ASSERT_TRUE( rt != nullptr );
+
+	sceneNode->update( Time::Zero );
+
+	const auto& richText = *rt->getRichTextPtr();
+	EXPECT_EQ( richText.getLines().size(), (size_t)2 );
+	EXPECT_STRINGEQ( richTextLineText( richText, 0 ), "alpha beta" );
+	EXPECT_STRINGEQ( richTextLineText( richText, 1 ), " gamma" );
+
+	destroyRichTextScene( sceneNode );
+}
+
+UTEST( UIRichText, WhiteSpaceCollapsePreserveSpaces ) {
+	auto sceneNode = createRichTextScene();
+	ASSERT_TRUE( sceneNode != nullptr );
+
+	String xml = R"xml(
+		alpha	 beta
+    gamma
+	)xml";
+
+	sceneNode->loadLayoutFromString( xml );
+	UI::UIRichText* rt = sceneNode->find( "rt" );
+	ASSERT_TRUE( rt != nullptr );
+
+	sceneNode->update( Time::Zero );
+
+	EXPECT_STRINGEQ( richTextRenderedText( *rt->getRichTextPtr() ), "alpha  beta     gamma" );
+
+	destroyRichTextScene( sceneNode );
+}
+
+UTEST( UIRichText, WhiteSpaceCollapsePreserveSpacesNormalizesCRLF ) {
+	auto sceneNode = createRichTextScene();
+	ASSERT_TRUE( sceneNode != nullptr );
+
+	String xml = R"xml(
+		alpha
beta
+	)xml";
+
+	sceneNode->loadLayoutFromString( xml );
+	UI::UIRichText* rt = sceneNode->find( "rt" );
+	ASSERT_TRUE( rt != nullptr );
+
+	sceneNode->update( Time::Zero );
+
+	EXPECT_STRINGEQ( richTextRenderedText( *rt->getRichTextPtr() ), "alpha beta" );
+
+	destroyRichTextScene( sceneNode );
+}
+
+UTEST( UIRichText, WhiteSpaceCollapseDiscard ) {
+	auto sceneNode = createRichTextScene();
+	ASSERT_TRUE( sceneNode != nullptr );
+
+	String xml = R"xml(
+		alpha 	 beta
+    gamma
+	)xml";
+
+	sceneNode->loadLayoutFromString( xml );
+	UI::UIRichText* rt = sceneNode->find( "rt" );
+	ASSERT_TRUE( rt != nullptr );
+
+	sceneNode->update( Time::Zero );
+
+	EXPECT_STRINGEQ( richTextRenderedText( *rt->getRichTextPtr() ), "alphabetagamma" );
+
+	destroyRichTextScene( sceneNode );
+}
+
+UTEST( UIRichText, WhiteSpaceCollapseAcrossInlineSpanBoundaries ) {
+	auto sceneNode = createRichTextScene();
+	ASSERT_TRUE( sceneNode != nullptr );
+
+	String xml = R"xml(
+		alpha  beta gamma
+	)xml";
+
+	sceneNode->loadLayoutFromString( xml );
+	UI::UIRichText* rt = sceneNode->find( "rt" );
+	ASSERT_TRUE( rt != nullptr );
+
+	sceneNode->update( Time::Zero );
+
+	EXPECT_STRINGEQ( richTextRenderedText( *rt->getRichTextPtr() ), "alpha beta gamma" );
+
+	destroyRichTextScene( sceneNode );
+}
+
+UTEST( RichText, BreakSpacesWrapsAfterPreservedSpacesAndTabs ) {
+	Engine::instance()->createWindow( WindowSettings( 800, 600, "RichText Test",
+													  WindowStyle::Default, WindowBackend::Default,
+													  32, {}, 1, false, true ) );
+	FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
+
+	FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
+	font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
+	ASSERT_TRUE( font->loaded() );
+	FontFamily::loadFromRegular( font );
+
+	FontStyleConfig style;
+	style.Font = font;
+	style.CharacterSize = 16;
+	auto textWidth = [&style]( const String& string, Uint32 tabWidth = 8 ) {
+		return Text::getTextWidth( string, style, tabWidth, TextHints::AllAscii );
+	};
+
+	RichText spacedText;
+	spacedText.setFontStyleConfig( style );
+	spacedText.setWhiteSpaceWrapMode( RichText::WhiteSpaceWrapMode::BreakSpaces );
+	spacedText.addSpan( "aa   bb" );
+	spacedText.setMaxWidth( textWidth( "aa   " ) + 1.f );
+	spacedText.getSize();
+	ASSERT_GE( spacedText.getLines().size(), (size_t)2 );
+	EXPECT_STRINGEQ( richTextLineText( spacedText, 0 ), "aa   " );
+	EXPECT_STRINGEQ( richTextLineText( spacedText, 1 ), "bb" );
+	EXPECT_GT( spacedText.getLines()[0].width, textWidth( "aa" ) );
+
+	RichText onlySpaces;
+	onlySpaces.setFontStyleConfig( style );
+	onlySpaces.setWhiteSpaceWrapMode( RichText::WhiteSpaceWrapMode::BreakSpaces );
+	onlySpaces.addSpan( "     " );
+	onlySpaces.setMaxWidth( textWidth( "   " ) + 1.f );
+	onlySpaces.getSize();
+	ASSERT_GE( onlySpaces.getLines().size(), (size_t)2 );
+	EXPECT_STRINGEQ( richTextLineText( onlySpaces, 0 ), "   " );
+	EXPECT_STRINGEQ( richTextLineText( onlySpaces, 1 ), "  " );
+
+	RichText tabText;
+	tabText.setFontStyleConfig( style );
+	tabText.setTabWidth( 4 );
+	tabText.setWhiteSpaceWrapMode( RichText::WhiteSpaceWrapMode::BreakSpaces );
+	tabText.addSpan( "a\tb" );
+	tabText.setMaxWidth( textWidth( "a\t", 4 ) + 1.f );
+	tabText.getSize();
+	ASSERT_GE( tabText.getLines().size(), (size_t)2 );
+	EXPECT_STRINGEQ( richTextLineText( tabText, 0 ), "a\t" );
+	EXPECT_STRINGEQ( richTextLineText( tabText, 1 ), "b" );
+
+	Engine::destroySingleton();
+}
+
 UTEST( UIHTMLTable, basicLayout ) {
 	auto sceneNode = createRichTextScene();
 	ASSERT_TRUE( sceneNode != nullptr );
diff --git a/src/tests/unit_tests/uihtml_tests.cpp b/src/tests/unit_tests/uihtml_tests.cpp
index d8cd0f29d..87b93692c 100644
--- a/src/tests/unit_tests/uihtml_tests.cpp
+++ b/src/tests/unit_tests/uihtml_tests.cpp
@@ -14,14 +14,18 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -48,6 +52,9 @@ static void init_ui_test() {
 	FontTrueType* font = FontTrueType::New( "NotoSans-Regular" );
 	font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
 	FontFamily::loadFromRegular( font );
+	FontTrueType* monospace = FontTrueType::New( "monospace" );
+	monospace->loadFromFile( "../assets/fonts/DejaVuSansMono.ttf" );
+	FontFamily::loadFromRegular( monospace );
 
 	UI::UISceneNode* sceneNode = UI::UISceneNode::New();
 	SceneManager::instance()->add( sceneNode );
@@ -56,6 +63,36 @@ static void init_ui_test() {
 	themeManager->setDefaultFont( font );
 }
 
+static String uiHtmlRenderedText( const RichText& richText ) {
+	String text;
+	const auto& lines = richText.getLines();
+	for ( size_t lineIndex = 0; lineIndex < lines.size(); ++lineIndex ) {
+		if ( lineIndex > 0 )
+			text += '\n';
+		for ( const auto& span : lines[lineIndex].spans ) {
+			if ( span.type == RichText::RenderSpan::Type::Text && span.text )
+				text += span.text->getString();
+		}
+	}
+	return text;
+}
+
+static String uiHtmlLineText( const RichText& richText, size_t lineIndex ) {
+	String text;
+	const auto& lines = richText.getLines();
+	if ( lineIndex >= lines.size() )
+		return text;
+	for ( const auto& span : lines[lineIndex].spans ) {
+		if ( span.type == RichText::RenderSpan::Type::Text && span.text )
+			text += span.text->getString();
+	}
+	while ( !text.empty() && ( text.front() == '\n' || text.front() == '\r' ) )
+		text = text.substr( 1 );
+	while ( !text.empty() && ( text.back() == '\n' || text.back() == '\r' ) )
+		text.pop_back();
+	return text;
+}
+
 UTEST( UIHTMLTable, complexLayout ) {
 	auto win = Engine::instance()->createWindow(
 		WindowSettings( 1024, 653, "HTML Tables Test", WindowStyle::Default, WindowBackend::Default,
@@ -348,8 +385,8 @@ UTEST( UIHTML, redditOldThreadWebViewSmoke ) {
 			  << ") "
 			  << "header=(" << headerPos.x << "," << headerPos.y << " "
 			  << header->asType()->getPixelsSize().getWidth() << "x"
-			  << header->asType()->getPixelsSize().getHeight() << " offset="
-			  << header->asType()->getPixelsContentOffset().Left << ","
+			  << header->asType()->getPixelsSize().getHeight()
+			  << " offset=" << header->asType()->getPixelsContentOffset().Left << ","
 			  << header->asType()->getPixelsContentOffset().Top << ","
 			  << header->asType()->getPixelsContentOffset().Right << ","
 			  << header->asType()->getPixelsContentOffset().Bottom << ") "
@@ -621,6 +658,322 @@ UTEST( UIHTML, WhiteSpaceNowrapContinuesInlineContentAfterOverflow ) {
 	Engine::destroySingleton();
 }
 
+UTEST( UIHTML, WhiteSpaceCollapsePreCodePreservesIndentation ) {
+	init_ui_test();
+	UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode();
+
+	sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html(
+		
+			
if (x) {
+    return 1;
+}
+ + )html" ) ); + SceneManager::instance()->update(); + + auto* pre = sceneNode->getRoot()->find( "pre" )->asType(); + auto* code = sceneNode->getRoot()->find( "code" ); + ASSERT_TRUE( pre != nullptr ); + ASSERT_TRUE( code != nullptr ); + + EXPECT_FALSE( code->isType( UI_TYPE_CODEEDITOR ) ); + EXPECT_GE( pre->getRichTextPtr()->getLines().size(), (size_t)3 ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 0 ), "if (x) {" ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 1 ), " return 1;" ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 2 ), "}" ); + + Engine::destroySingleton(); +} + +UTEST( UIHTML, PreCodeSimpleFixtureKeepsCompactCodeLines ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->setURI( "file://" + Sys::getProcessPath() + "assets/html/" ); + + std::string html; + ASSERT_TRUE( FileSystem::fileGet( "assets/html/pre.code.html", html ) ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + SceneManager::instance()->update(); + + UIRichText* pre = nullptr; + Node* code = nullptr; + sceneNode->getRoot()->forEachNode( [&pre, &code]( Node* node ) { + if ( node->isWidget() && node->asType()->getElementTag() == "pre" ) + pre = node->asType(); + if ( node->isWidget() && node->asType()->getElementTag() == "code" ) + code = node; + } ); + ASSERT_TRUE( pre != nullptr ); + ASSERT_TRUE( code != nullptr ); + EXPECT_FALSE( code->isType( UI_TYPE_CODEEDITOR ) ); + auto* codeSpan = code->asType(); + ASSERT_TRUE( codeSpan != nullptr ); + + const auto& lines = pre->getRichTextPtr()->getLines(); + ASSERT_GE( lines.size(), (size_t)3 ); + ASSERT_LE( lines.size(), (size_t)4 ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 0 ), "void main() {" ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 1 ), " printf(\"Hello World\");" ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 2 ), "}" ); + if ( lines.size() == 4 ) + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 3 ), "" ); + ASSERT_FALSE( lines[1].spans.empty() ); + ASSERT_TRUE( lines[1].spans[0].text != nullptr ); + ASSERT_FALSE( lines[1].spans[0].text->getString().empty() ); + EXPECT_TRUE( lines[1].spans[0].text->getString()[0] != '\n' ); + ASSERT_FALSE( lines[2].spans.empty() ); + ASSERT_TRUE( lines[2].spans[0].text != nullptr ); + ASSERT_FALSE( lines[2].spans[0].text->getString().empty() ); + EXPECT_TRUE( lines[2].spans[0].text->getString()[0] != '\n' ); + + ASSERT_EQ( codeSpan->getHitBoxes().size(), (size_t)3 ); + Float expectedLineHeight = pre->getLineHeightPx(); + if ( expectedLineHeight <= 0.f ) { + for ( const auto& span : lines[0].spans ) { + if ( span.type == RichText::RenderSpan::Type::Text && span.text && + !span.text->getString().empty() ) { + const auto& fontStyle = span.text->getFontStyleConfig(); + ASSERT_TRUE( fontStyle.Font != nullptr ); + expectedLineHeight = fontStyle.Font->getLineSpacing( fontStyle.CharacterSize ); + break; + } + } + } + ASSERT_GT( expectedLineHeight, 0.f ); + Float compactLineLimit = expectedLineHeight * 1.25f; + for ( size_t i = 1; i < 3; ++i ) { + Float lineDelta = lines[i].y - lines[i - 1].y; + EXPECT_LE( lineDelta, compactLineLimit ); + EXPECT_LE( lines[i].height, compactLineLimit ); + + const Rectf& prev = codeSpan->getHitBoxes()[i - 1]; + const Rectf& cur = codeSpan->getHitBoxes()[i]; + EXPECT_NEAR( cur.Top - prev.Top, lineDelta, 1.f ); + EXPECT_LE( cur.Top - prev.Top, compactLineLimit ); + } + + Engine::destroySingleton(); +} + +UTEST( UIHTML, PreCodeFixtureDiscardsFinalNewlineBeforeEndTag ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->setURI( "file://" + Sys::getProcessPath() + "assets/html/" ); + + std::string html; + ASSERT_TRUE( FileSystem::fileGet( "assets/html/pre.code.2.html", html ) ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + SceneManager::instance()->update(); + + UIRichText* pre = nullptr; + Node* code = nullptr; + sceneNode->getRoot()->forEachNode( [&pre, &code]( Node* node ) { + if ( node->isWidget() && node->asType()->getElementTag() == "pre" ) + pre = node->asType(); + if ( node->isWidget() && node->asType()->getElementTag() == "code" ) + code = node; + } ); + ASSERT_TRUE( pre != nullptr ); + ASSERT_TRUE( code != nullptr ); + EXPECT_FALSE( code->isType( UI_TYPE_CODEEDITOR ) ); + + const auto& lines = pre->getRichTextPtr()->getLines(); + ASSERT_EQ( lines.size(), (size_t)3 ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 0 ), "int x = 42;" ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 1 ), "const char* y = \"hello\";" ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 2 ), "int z = foo(x, y);" ); + + Engine::destroySingleton(); +} + +UTEST( UIHTML, PreCodeUsesCodeEditorOnlyForMarkdownAncestorOrGlobalOptIn ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + sceneNode->loadLayoutFromString( R"xml( + +
void main() {
+    printf("Hello World");
+}
+
+ )xml" ); + SceneManager::instance()->update(); + + auto* markdownCode = sceneNode->getRoot()->find( "md_code" ); + ASSERT_TRUE( markdownCode != nullptr ); + ASSERT_TRUE( markdownCode->isType( UI_TYPE_CODEEDITOR ) ); + EXPECT_TRUE( markdownCode->asType()->getDocument().getText().find( "printf" ) != + String::InvalidPos ); + + Engine::destroySingleton(); + + init_ui_test(); + sceneNode = SceneManager::instance()->getUISceneNode(); + UIRichText::setUseCodeEditorForPreCodeBlocks( true ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + +
int value = 1;
+ + )html" ) ); + SceneManager::instance()->update(); + + auto* optInCode = sceneNode->getRoot()->find( "code" ); + ASSERT_TRUE( optInCode != nullptr ); + EXPECT_TRUE( optInCode->isType( UI_TYPE_CODEEDITOR ) ); + UIRichText::setUseCodeEditorForPreCodeBlocks( false ); + + Engine::destroySingleton(); +} + +UTEST( UIHTML, PreCodeBlockFixtureKeepsCompactCodeLines ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->setURI( "file://" + Sys::getProcessPath() + "assets/html/" ); + + std::string html; + ASSERT_TRUE( FileSystem::fileGet( "assets/html/pre_code_block.html", html ) ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + SceneManager::instance()->update(); + + UIRichText* pre = nullptr; + sceneNode->getRoot()->forEachNode( [&pre]( Node* node ) { + if ( pre == nullptr && node->isWidget() && + node->asType()->getElementTag() == "pre" ) + pre = node->asType(); + } ); + ASSERT_TRUE( pre != nullptr ); + + const auto& lines = pre->getRichTextPtr()->getLines(); + ASSERT_EQ( lines.size(), (size_t)19 ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 0 ), "let stepper () =" ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 1 ), + " // Execute a single instruction" ); + EXPECT_STRINGEQ( uiHtmlLineText( *pre->getRichTextPtr(), 18 ), " mCycles " ); + + Float expectedLineHeight = pre->getLineHeightPx(); + ASSERT_GT( expectedLineHeight, 0.f ); + Float compactLineLimit = expectedLineHeight * 1.25f; + for ( size_t i = 1; i < lines.size(); ++i ) { + EXPECT_LE( lines[i].y - lines[i - 1].y, compactLineLimit ); + EXPECT_LE( lines[i].height, compactLineLimit ); + } + + std::vector codeLineSpans; + for ( size_t i = 1; i <= 19; ++i ) { + auto* lineSpan = sceneNode->getRoot()->find( String::format( "cb1-%zu", i ) ); + ASSERT_TRUE( lineSpan != nullptr ); + ASSERT_EQ( lineSpan->getHitBoxes().size(), (size_t)1 ); + EXPECT_LE( lineSpan->getHitBoxes()[0].getHeight(), compactLineLimit ); + codeLineSpans.push_back( lineSpan ); + } + for ( size_t i = 1; i < codeLineSpans.size(); ++i ) { + const Rectf& prev = codeLineSpans[i - 1]->getHitBoxes()[0]; + const Rectf& cur = codeLineSpans[i]->getHitBoxes()[0]; + Float prevTop = codeLineSpans[i - 1]->getPixelsPosition().y + prev.Top; + Float curTop = codeLineSpans[i]->getPixelsPosition().y + cur.Top; + EXPECT_NEAR( curTop - prevTop, lines[i].y - lines[i - 1].y, 1.f ); + EXPECT_LE( curTop - prevTop, compactLineLimit ); + } + + Engine::destroySingleton(); +} + +UTEST( UIHTML, WhiteSpaceCollapsePreLinePreservesBreaksOnly ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + +
alpha beta + gamma
+ + )html" ) ); + SceneManager::instance()->update(); + + auto* preline = sceneNode->getRoot()->find( "preline" )->asType(); + ASSERT_TRUE( preline != nullptr ); + + EXPECT_EQ( preline->getRichTextPtr()->getLines().size(), (size_t)2 ); + EXPECT_STRINGEQ( uiHtmlLineText( *preline->getRichTextPtr(), 0 ), "alpha beta" ); + EXPECT_STRINGEQ( uiHtmlLineText( *preline->getRichTextPtr(), 1 ), " gamma" ); + + Engine::destroySingleton(); +} + +UTEST( UIHTML, WhiteSpaceCollapseBreakSpacesAffectsIntrinsicWidth ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + +
+
+ + )html" ) ); + SceneManager::instance()->update(); + + auto* normal = sceneNode->getRoot()->find( "normal" )->asType(); + auto* breakSpaces = sceneNode->getRoot()->find( "breakspaces" )->asType(); + ASSERT_TRUE( normal != nullptr ); + ASSERT_TRUE( breakSpaces != nullptr ); + + EXPECT_LT( normal->getMinIntrinsicWidth(), breakSpaces->getMinIntrinsicWidth() ); + EXPECT_GT( breakSpaces->getMinIntrinsicWidth(), 0.f ); + EXPECT_STRINGEQ( uiHtmlRenderedText( *breakSpaces->getRichTextPtr() ), " " ); + + Engine::destroySingleton(); +} + +UTEST( UIHTML, WhiteSpaceCollapsePreservedTabsUseTabSize ) { + init_ui_test(); + UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode(); + + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( + +
a	b
+
a	b
+
a	b
+ + )html" ) ); + SceneManager::instance()->update(); + + auto* tab2 = sceneNode->getRoot()->find( "tab2" )->asType(); + auto* tab8 = sceneNode->getRoot()->find( "tab8" )->asType(); + auto* tabpx = sceneNode->getRoot()->find( "tabpx" )->asType(); + ASSERT_TRUE( tab2 != nullptr ); + ASSERT_TRUE( tab8 != nullptr ); + ASSERT_TRUE( tabpx != nullptr ); + + ASSERT_EQ( tab2->getRichTextPtr()->getLines().size(), (size_t)1 ); + ASSERT_EQ( tab8->getRichTextPtr()->getLines().size(), (size_t)1 ); + ASSERT_EQ( tabpx->getRichTextPtr()->getLines().size(), (size_t)1 ); + EXPECT_STRINGEQ( uiHtmlLineText( *tab2->getRichTextPtr(), 0 ), "a\tb" ); + EXPECT_STRINGEQ( uiHtmlLineText( *tab8->getRichTextPtr(), 0 ), "a\tb" ); + EXPECT_STRINGEQ( uiHtmlLineText( *tabpx->getRichTextPtr(), 0 ), "a\tb" ); + EXPECT_EQ( tab2->getTabSize(), 2u ); + EXPECT_EQ( tab8->getTabSize(), 8u ); + EXPECT_GT( tabpx->getTabSize(), 2u ); + + const Float tab2Width = tab2->getRichTextPtr()->getLines()[0].width; + const Float tab8Width = tab8->getRichTextPtr()->getLines()[0].width; + const Float tabpxWidth = tabpx->getRichTextPtr()->getLines()[0].width; + EXPECT_GT( tab8Width, tab2Width ); + EXPECT_GT( tabpxWidth, tab2Width ); + + ASSERT_FALSE( tab2->getRichTextPtr()->getLines()[0].spans.empty() ); + ASSERT_FALSE( tab8->getRichTextPtr()->getLines()[0].spans.empty() ); + ASSERT_FALSE( tabpx->getRichTextPtr()->getLines()[0].spans.empty() ); + ASSERT_TRUE( tab2->getRichTextPtr()->getLines()[0].spans[0].text != nullptr ); + ASSERT_TRUE( tab8->getRichTextPtr()->getLines()[0].spans[0].text != nullptr ); + ASSERT_TRUE( tabpx->getRichTextPtr()->getLines()[0].spans[0].text != nullptr ); + EXPECT_EQ( tab2->getRichTextPtr()->getLines()[0].spans[0].text->getTabWidth(), 2u ); + EXPECT_EQ( tab8->getRichTextPtr()->getLines()[0].spans[0].text->getTabWidth(), 8u ); + EXPECT_EQ( tabpx->getRichTextPtr()->getLines()[0].spans[0].text->getTabWidth(), + tabpx->getTabSize() ); + + Engine::destroySingleton(); +} + UTEST( UIRichText, anchorMargins ) { auto win = Engine::instance()->createWindow( WindowSettings( 800, 600, "Anchor Margins Test", WindowStyle::Default, @@ -2589,8 +2942,7 @@ UTEST( UIHTML, BodyHeightMiscalculationFixture ) { // With justify-content: space-between in a row-direction flex: // .brand should be at the left side of .wrap (near padding edge). - EXPECT_LT( brandWidget->getPixelsPosition().x, - wrapWidget->getPixelsSize().getWidth() * 0.25f ); + EXPECT_LT( brandWidget->getPixelsPosition().x, wrapWidget->getPixelsSize().getWidth() * 0.25f ); // .links should be at the right side of .wrap, NOT next to .brand. Float wrapW = wrapWidget->getPixelsSize().getWidth(); @@ -3001,7 +3353,7 @@ UTEST( UIHTML, AnchorsSizing ) { continue; EXPECT_GT( a->getPixelsSize().getHeight(), 0 ); if ( !a->getText().empty() && a->getFontStyleConfig().Font ) { - String text = a->getText(); + String text = UIRichText::collapseInternalWhitespace( a->getText() ); text.trim(); if ( !text.empty() ) EXPECT_GE( a->getPixelsSize().getWidth(), diff --git a/src/tests/unit_tests/uitextnode_tests.cpp b/src/tests/unit_tests/uitextnode_tests.cpp index 76da7e47f..6a49f2ae8 100644 --- a/src/tests/unit_tests/uitextnode_tests.cpp +++ b/src/tests/unit_tests/uitextnode_tests.cpp @@ -954,7 +954,7 @@ UTEST( UITextNode_EdgeCases, EmptyTextNodesDontAffectLayout ) { if ( child ) { EXPECT_TRUE( child->isTextNode() ); UITextNode* textNode = static_cast( child ); - EXPECT_TRUE( textNode->getText().empty() || String::trim( textNode->getText() ).empty() ); + EXPECT_EQ( textNode->getLayoutCharCount(), (size_t)0 ); } destroyRichTextScene( sceneNode );