Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
Martín Lucas Golini
2026-06-01 21:16:06 -03:00
15 changed files with 1624 additions and 180 deletions

View File

@@ -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 `<pre><code>` renders as normal RichText content.
- The old read-only `UICodeEditor` initialization is retained only when a `<pre><code>` 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
`<pre><code>...\n</code></pre>`.
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.
- `<pre><code>` 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.
- `<pre><code>` 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 `<pre><code>` Fallback
### Phase 6: Replace The Generic `<pre><code>` 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 `<code>` 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 `<code>` 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 `<pre>`.
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 `<pre><code>` 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 `<pre>`/`<code>` 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 `<pre><code>` `UICodeEditor` fallback.
6. Restrict the `<pre><code>` `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.
- `<pre><code>` renders through normal HTML/RichText nodes, not `UICodeEditor`.
- Generic HTML `<pre><code>` renders through normal HTML/RichText nodes, not `UICodeEditor`.
- Markdown `<pre><code>` can still render through read-only `UICodeEditor`.
- Code indentation and blank lines are preserved in `<pre>` 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.

View File

@@ -0,0 +1,22 @@
<html>
<head>
<style>
pre {
background-color: #f8f8f8;
border: 1px solid #e5e5e5;
border-left: 4px solid #c41230;
padding: 1em;
margin-bottom: 1em;
overflow-x: auto;
font-size: 0.95em;
line-height: 1.4;
}
</style>
</head>
<body>
<pre><code>int x = 42;
const char* y = "hello";
int z = foo(x, y);
</code></pre>
</body>
</html>

View File

@@ -0,0 +1,6 @@
<html><head></head><body>
<pre><code style="background-color: #ccc" class="language-cpp">void main() {
printf(&quot;Hello World&quot;);
}
</code></pre>
</body></html>

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en"><head>
<style>
:root {
--bg: #ffffff;
--text: #1f2937;
--code-bg: #f3f4f6;
--code-border: #e5e7eb;
}
html { font-size: 16px; background: var(--bg); }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.7;
color: var(--text);
background: var(--bg);
max-width: 750px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.875em;
background: var(--code-bg);
padding: 0.15em 0.35em;
border-radius: 4px;
}
pre {
background: var(--code-bg);
border: 1px solid var(--code-border);
border-radius: 6px;
padding: 1rem;
overflow-x: auto;
line-height: 1.5;
}
pre code span {
font-weight: normal !important;
font-style: normal !important;
}
pre code {
background: none;
padding: 0;
border-radius: 0;
font-size: 0.85em;
}
div.sourceCode {
background: var(--code-bg);
border: 1px solid var(--code-border);
border-radius: 6px;
margin: 1rem 0;
}
div.sourceCode pre {
border: none;
margin: 0;
}
code span.kw { color: #cf222e; }
code span.op { color: #cf222e; }
code span.dv, code span.fl { color: #0550ae; }
code span.co { color: #6e7781; }
</style>
</head>
<body>
<div class="sourceCode" id="cb1"><pre class="sourceCode fsharp"><code class="sourceCode fsharp"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> stepper <span class="op">()</span> =</span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a> //<span class="co"> Execute a single instruction</span></span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a> //<span class="co"> Each instruction uses a different amount of cycles</span></span>
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> mCycles = stepCpu cpu io</span>
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb1-6"><a href="#cb1-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">for</span> _ <span class="kw">in</span> <span class="fl">1.</span>.mCycles <span class="kw">do</span> </span>
<span id="cb1-7"><a href="#cb1-7" aria-hidden="true" tabindex="-1"></a> stepTimers timer io </span>
<span id="cb1-8"><a href="#cb1-8" aria-hidden="true" tabindex="-1"></a> stepSerial serial io</span>
<span id="cb1-9"><a href="#cb1-9" aria-hidden="true" tabindex="-1"></a> //<span class="co"> The APU technically runs at 4x CPU-cycles, but can be batched</span></span>
<span id="cb1-10"><a href="#cb1-10" aria-hidden="true" tabindex="-1"></a> stepApu apu </span>
<span id="cb1-11"><a href="#cb1-11" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb1-12"><a href="#cb1-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> tCycles = mCycles * <span class="dv">4</span> </span>
<span id="cb1-13"><a href="#cb1-13" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb1-14"><a href="#cb1-14" aria-hidden="true" tabindex="-1"></a> //<span class="co"> The PPU operates at 4x CPU-cycles. The APU should be here too</span></span>
<span id="cb1-15"><a href="#cb1-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">for</span> _ <span class="kw">in</span> <span class="fl">1.</span>.tCycles <span class="kw">do</span> </span>
<span id="cb1-16"><a href="#cb1-16" aria-hidden="true" tabindex="-1"></a> stepPpu ppu </span>
<span id="cb1-17"><a href="#cb1-17" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb1-18"><a href="#cb1-18" aria-hidden="true" tabindex="-1"></a> //<span class="co"> Return cycles taken so the frontend runs the emulator at the right speed</span></span>
<span id="cb1-19"><a href="#cb1-19" aria-hidden="true" tabindex="-1"></a> mCycles </span></code></pre></div>
</body></html>

View File

@@ -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<FloatExclusion>& exclusions );
const std::vector<FloatExclusion>& 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 <typename TextType>
void addSpanImpl( TextType&& text, const FontStyleConfig& style, const Rectf& margin,
const Rectf& padding, Float lineHeight,
const BaselineAlignValue& baselineAlign, InlineSource source );
template <typename TextType>
void addInlineTextImpl( TextType&& text, const FontStyleConfig& style, const Rectf& margin,
const Rectf& padding, Float lineHeight,
const BaselineAlignValue& baselineAlign, InlineSource source );
void rebuildInlineFragments();
std::vector<InlineItem> 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

View File

@@ -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" ),

View File

@@ -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 };

View File

@@ -474,6 +474,17 @@ static void invalidateInlineItemText( std::vector<RichText::InlineItem>& items )
}
}
static void setInlineItemTextTabWidth( std::vector<RichText::InlineItem>& 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<RichText::InlineItem>& inlineItems ) {
static Float getMinIntrinsicWidth( const std::vector<RichText::InlineItem>& 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<RichText::InlineItem>& 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<RichText::InlineItem>& inlineItems, Float maxLayoutWidth,
Float textIndent, Uint32 align, Float forcedLineHeight, const FontStyleConfig& defaultStyle,
const std::vector<RichText::FloatExclusion>& externalFloatExclusions, bool lineWrap ) {
static LayoutResult
layoutWithFloats( const std::vector<RichText::InlineItem>& inlineItems, Float maxLayoutWidth,
Float textIndent, Uint32 align, Float forcedLineHeight,
const FontStyleConfig& defaultStyle,
const std::vector<RichText::FloatExclusion>& 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<Int64>( 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<Int64>( 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 <typename TextType> static void setTextString( Text& text, TextType&& string ) {
text.setString( std::forward<TextType>( string ) );
}
template <typename TextType>
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<Text>();
span->setString( text );
setTextString( *span, std::forward<TextType>( 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> 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 <typename TextType>
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<Text>();
run.text->setString( text );
setTextString( *run.text, std::forward<TextType>( 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<Uint32>( 1, tabWidth );
if ( mTabWidth != tabWidth ) {
mTabWidth = tabWidth;
setInlineItemTextTabWidth( mInlineItems, mTabWidth );
invalidateLayout();
}
}
bool RichText::setExternalFloatExclusions( const std::vector<FloatExclusion>& 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;

View File

@@ -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" );

View File

@@ -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<Uint32>(
1, static_cast<Uint32>( 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<PropertyId> 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<Uint32>( 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 <pre><code>
if ( mTag == "pre" && String::iequals( child.name(), "code" ) &&
( sUseCodeEditorForPreCodeBlocks || hasMarkdownViewAncestor( this ) ) ) {
// Use a UICodeEditor for markdown <pre><code> 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<UIRichText>()->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<UIRichText*>( 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<UIRichText*>( container )->getWhiteSpaceCollapse() ==
WhiteSpaceCollapse::Collapse
: true;
UIRichText* containerRichText =
container->isType( UI_TYPE_RICHTEXT ) || container->isType( UI_TYPE_TEXTSPAN )
? container->asType<UIRichText>()
: 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<UITextSpan>();
Node* parentNode = container->getParent();
@@ -1199,19 +1636,23 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
parentNode->asType<UIHTMLWidget>()->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<UITextNode>();
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<UIWidget>()->isInlineDisplay();
@@ -1382,29 +1834,26 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
bool nextIsInline =
next && next->isWidget() && next->asType<UIWidget>()->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--;

View File

@@ -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<UIHTMLWidget>()->isFlex() ) ) {
if ( !isInline() || ( getParent() && getParent()->isType( UI_TYPE_HTML_WIDGET ) &&
getParent()->asType<UIHTMLWidget>()->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();
}
}
}

View File

@@ -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; }

View File

@@ -6,6 +6,7 @@
#include <eepp/graphics/fonttruetype.hpp>
#include <eepp/graphics/primitives.hpp>
#include <eepp/graphics/richtext.hpp>
#include <eepp/graphics/text.hpp>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/scopedop.hpp>
@@ -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(
<RichText id="rt" white-space-collapse="preserve" white-space="pre-wrap">alpha beta
gamma</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "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(
<RichText id="rt" white-space-collapse="preserve-breaks">alpha beta
gamma</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "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(
<RichText id="rt" white-space-collapse="preserve-spaces">alpha beta
gamma</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "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(
<RichText id="rt" white-space-collapse="preserve-spaces">alpha&#13;&#10;beta</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "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(
<RichText id="rt" white-space-collapse="discard">alpha beta
gamma</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "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(
<RichText id="rt">alpha <span> beta</span> gamma</RichText>
)xml";
sceneNode->loadLayoutFromString( xml );
UI::UIRichText* rt = sceneNode->find<UI::UIRichText>( "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 );

View File

@@ -14,14 +14,18 @@
#include <eepp/ui/tools/htmlformatter.hpp>
#include <eepp/ui/tools/uiwidgetinspector.hpp>
#include <eepp/ui/uicheckbox.hpp>
#include <eepp/ui/uicodeeditor.hpp>
#include <eepp/ui/uihtmldetails.hpp>
#include <eepp/ui/uihtmlinput.hpp>
#include <eepp/ui/uihtmltable.hpp>
#include <eepp/ui/uihtmltextarea.hpp>
#include <eepp/ui/uihtmltextinput.hpp>
#include <eepp/ui/uimarkdownview.hpp>
#include <eepp/ui/uinodedrawable.hpp>
#include <eepp/ui/uiradiobutton.hpp>
#include <eepp/ui/uirichtext.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uitextnode.hpp>
#include <eepp/ui/uitextspan.hpp>
#include <eepp/ui/uitheme.hpp>
#include <eepp/ui/uithememanager.hpp>
@@ -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<UIWidget>()->getPixelsSize().getWidth() << "x"
<< header->asType<UIWidget>()->getPixelsSize().getHeight() << " offset="
<< header->asType<UIWidget>()->getPixelsContentOffset().Left << ","
<< header->asType<UIWidget>()->getPixelsSize().getHeight()
<< " offset=" << header->asType<UIWidget>()->getPixelsContentOffset().Left << ","
<< header->asType<UIWidget>()->getPixelsContentOffset().Top << ","
<< header->asType<UIWidget>()->getPixelsContentOffset().Right << ","
<< header->asType<UIWidget>()->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(
<body>
<pre id="pre"><code id="code">if (x) {
return 1;
}</code></pre>
</body>
)html" ) );
SceneManager::instance()->update();
auto* pre = sceneNode->getRoot()->find( "pre" )->asType<UIRichText>();
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<UIWidget>()->getElementTag() == "pre" )
pre = node->asType<UIRichText>();
if ( node->isWidget() && node->asType<UIWidget>()->getElementTag() == "code" )
code = node;
} );
ASSERT_TRUE( pre != nullptr );
ASSERT_TRUE( code != nullptr );
EXPECT_FALSE( code->isType( UI_TYPE_CODEEDITOR ) );
auto* codeSpan = code->asType<UITextSpan>();
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<UIWidget>()->getElementTag() == "pre" )
pre = node->asType<UIRichText>();
if ( node->isWidget() && node->asType<UIWidget>()->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(
<markdownview id="md">
<pre id="md_pre"><code id="md_code" class="language-cpp">void main() {
printf("Hello World");
}</code></pre>
</markdownview>
)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<UICodeEditor>()->getDocument().getText().find( "printf" ) !=
String::InvalidPos );
Engine::destroySingleton();
init_ui_test();
sceneNode = SceneManager::instance()->getUISceneNode();
UIRichText::setUseCodeEditorForPreCodeBlocks( true );
sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html(
<body>
<pre id="pre"><code id="code">int value = 1;</code></pre>
</body>
)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<UIWidget>()->getElementTag() == "pre" )
pre = node->asType<UIRichText>();
} );
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<UITextSpan*> codeLineSpans;
for ( size_t i = 1; i <= 19; ++i ) {
auto* lineSpan = sceneNode->getRoot()->find<UITextSpan>( 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(
<body>
<div id="preline" style="white-space: pre-line">alpha beta
gamma</div>
</body>
)html" ) );
SceneManager::instance()->update();
auto* preline = sceneNode->getRoot()->find( "preline" )->asType<UIRichText>();
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(
<body>
<div id="normal" style="white-space: pre-wrap; font-size: 16px"> </div>
<div id="breakspaces" style="white-space: break-spaces; font-size: 16px"> </div>
</body>
)html" ) );
SceneManager::instance()->update();
auto* normal = sceneNode->getRoot()->find( "normal" )->asType<UIRichText>();
auto* breakSpaces = sceneNode->getRoot()->find( "breakspaces" )->asType<UIRichText>();
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(
<body>
<pre id="tab2" style="font-family: monospace; font-size: 16px; tab-size: 2">a b</pre>
<pre id="tab8" style="font-family: monospace; font-size: 16px; tab-size: 8">a b</pre>
<pre id="tabpx" style="font-family: monospace; font-size: 16px; tab-size: 32px">a b</pre>
</body>
)html" ) );
SceneManager::instance()->update();
auto* tab2 = sceneNode->getRoot()->find( "tab2" )->asType<UIRichText>();
auto* tab8 = sceneNode->getRoot()->find( "tab8" )->asType<UIRichText>();
auto* tabpx = sceneNode->getRoot()->find( "tabpx" )->asType<UIRichText>();
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(),

View File

@@ -954,7 +954,7 @@ UTEST( UITextNode_EdgeCases, EmptyTextNodesDontAffectLayout ) {
if ( child ) {
EXPECT_TRUE( child->isTextNode() );
UITextNode* textNode = static_cast<UITextNode*>( child );
EXPECT_TRUE( textNode->getText().empty() || String::trim( textNode->getText() ).empty() );
EXPECT_EQ( textNode->getLayoutCharCount(), (size_t)0 );
}
destroyRichTextScene( sceneNode );