ui: improve HTML inline layout and old Reddit rendering

- implement per-side CSS border styles and patterned rendering
  - preserve rectangular rasterization for thin dotted borders
  - separate CSS and native-widget border defaults
  - correct inline atomic-box and vertical-align baseline layout
  - collapse adjacent block margins in rich-text flow
  - preserve forced line breaks and formatting margins
  - size HTML text inputs from font ascent and descent
  - expose replaced-control text baselines to inline layout
  - enforce single-owner painting for inline backgrounds and borders
  - enable and update the old Reddit visual regression golden
  - add generic layout, border, and form-control regression tests
  - document spec-first HTML work and project build conventions
This commit is contained in:
Martín Lucas Golini
2026-08-02 17:48:53 -03:00
parent 2df0861d76
commit 48b7e7c21f
32 changed files with 1344 additions and 1455 deletions
+59 -23
View File
@@ -78,7 +78,7 @@ static Float getBaselineAlignedOffset( const RichText::RenderParagraph& line, co
case RichText::BaselineAlignment::TextBottom:
return baseline + getParentDescent( parentFontStyle ) - size.getHeight();
case RichText::BaselineAlignment::Middle:
return baseline + getFontXHeight( parentFontStyle ) * 0.5f - size.getHeight() * 0.5f;
return baseline - getFontXHeight( parentFontStyle ) * 0.5f - size.getHeight() * 0.5f;
case RichText::BaselineAlignment::Top:
return 0.f;
case RichText::BaselineAlignment::Bottom:
@@ -848,7 +848,8 @@ class RichTextInlineLayouter {
const FontStyleConfig& defaultStyle, Float forcedLineHeight,
bool preserveFloatPositions,
const std::vector<RichText::InlineItem>& inlineItems ) {
recomputeLineMetrics( line, forcedLineHeight, preserveFloatPositions, inlineItems );
recomputeLineMetrics( line, forcedLineHeight, preserveFloatPositions, inlineItems,
defaultStyle );
Float maxLineHeight = 0;
Float minLineTop = 0;
@@ -870,15 +871,8 @@ class RichTextInlineLayouter {
Float baseline = span.baseline;
RichText::BaselineAlignValue baselineAlign = effectiveInlineBaselineAlign(
inlineItems, span.inlinePath, span.baselineAlign );
// Empty atomic boxes use their bottom edge as the fallback baseline. Include that
// exact edge in the middle-alignment path or fixed-height inline parents can clip
// the box after it is displaced by a second baseline formula.
Float offsetY = baselineAlign.type == RichText::BaselineAlignment::Middle &&
baseline > 0.f && baseline <= span.size.getHeight()
? line.maxAscent - baseline
: getBaselineAlignedOffset( line, span.size, baseline,
span.size.getHeight(),
baselineAlign, defaultStyle );
Float offsetY = getBaselineAlignedOffset(
line, span.size, baseline, span.size.getHeight(), baselineAlign, defaultStyle );
if ( preserveFloatPositions && isFloat ) {
span.position.y = 0;
continue;
@@ -902,6 +896,8 @@ class RichTextInlineLayouter {
line.height = std::max( line.height, maxLineHeight );
if ( forcedLineHeight > 0 )
line.height = std::max( line.height, forcedLineHeight );
if ( line.forcedEmptyLine && line.height <= 0.f && defaultStyle.Font )
line.height = defaultStyle.Font->getFontHeight( defaultStyle.CharacterSize );
}
static LayoutResult layoutNoFloats( const std::vector<RichText::InlineItem>& inlineItems,
@@ -995,8 +991,13 @@ class RichTextInlineLayouter {
if ( metrics.isLineBreak ) {
maxWidth = std::max( maxWidth, curX );
if ( !result.lines.back().spans.empty() )
result.lines.back().height =
std::max( result.lines.back().height, metrics.size.getHeight() );
if ( !result.lines.back().spans.empty() || metrics.forceEmptyLine ) {
if ( result.lines.back().spans.empty() )
result.lines.back().forcedEmptyLine = true;
result.lines.push_back( RichText::RenderParagraph() );
}
curX = 0;
continue;
}
@@ -1034,7 +1035,7 @@ class RichTextInlineLayouter {
maxWidth = std::max( maxWidth, curX );
if ( !result.lines.empty() && result.lines.back().spans.empty() &&
result.lines.size() > 1 ) {
!result.lines.back().forcedEmptyLine && result.lines.size() > 1 ) {
result.lines.pop_back();
}
@@ -1241,8 +1242,15 @@ class RichTextInlineLayouter {
if ( metrics.isLineBreak ) {
maxWidth = std::max( maxWidth, curX );
if ( !result.lines.back().spans.empty() &&
lineHasInFlowContent( result.lines.back() ) ) {
result.lines.back().height =
std::max( result.lines.back().height, metrics.size.getHeight() );
if ( metrics.forceEmptyLine && result.lines.back().spans.empty() )
result.lines.back().forcedEmptyLine = true;
if ( ( !result.lines.back().spans.empty() &&
lineHasInFlowContent( result.lines.back() ) ) ||
metrics.forceEmptyLine ) {
alignLineSpans( result.lines.back(), 0.f, defaultStyle, forcedLineHeight,
true, inlineItems );
curY += result.lines.back().height;
result.lines.push_back( RichText::RenderParagraph() );
result.lines.back().y = curY;
@@ -1432,7 +1440,7 @@ class RichTextInlineLayouter {
maxWidth = std::max( maxWidth, curX );
if ( !result.lines.empty() && result.lines.back().spans.empty() &&
result.lines.size() > 1 ) {
!result.lines.back().forcedEmptyLine && result.lines.size() > 1 ) {
result.lines.pop_back();
}
@@ -1616,6 +1624,7 @@ class RichTextInlineLayouter {
fragment.itemPath = leaf->path;
fragment.lineIndex = lineIndex;
fragment.bounds = bounds;
fragment.formattingMargin = span.formattingMargin;
fragment.startCharIndex = span.startCharIndex;
fragment.endCharIndex = span.endCharIndex;
fragment.baselineAlign = effectiveInlineBaselineAlign(
@@ -1642,6 +1651,7 @@ class RichTextInlineLayouter {
Sizef size;
Float baseline{ 0.f };
bool isLineBreak{ false };
bool forceEmptyLine{ false };
bool isBlock{ false };
bool isBlockFormattingContext{ false };
RichText::InlineFloat floatType{ RichText::InlineFloat::None };
@@ -1712,10 +1722,12 @@ class RichTextInlineLayouter {
: RichText::RenderSpan::Type::AtomicBox;
run.payload.drawable = box.drawable;
run.payload.size = box.drawable ? box.drawable->getPixelsSize() : box.size;
run.payload.formattingMargin = box.formattingMargin;
run.payload.baseline = box.drawable ? run.payload.size.getHeight() : box.baseline;
run.payload.floatType = box.floatType;
run.payload.clearType = box.clearType;
run.payload.isLineBreak = box.isLineBreak;
run.payload.forceEmptyLine = box.forceEmptyLine;
run.payload.isBlock = box.isBlock;
run.payload.isBlockFormattingContext = box.isBlockFormattingContext;
run.payload.propagatedFloats = box.propagatedFloats;
@@ -1862,12 +1874,20 @@ class RichTextInlineLayouter {
static void recomputeLineMetrics( RichText::RenderParagraph& line, Float forcedLineHeight,
bool preserveFloatPositions,
const std::vector<RichText::InlineItem>& inlineItems ) {
const std::vector<RichText::InlineItem>& inlineItems,
const FontStyleConfig& defaultStyle ) {
Float maxAscent = 0.f;
Float maxDescent = 0.f;
bool hasParticipatingLineHeight = forcedLineHeight > 0.f;
bool hasMiddleAlignedSpan = false;
for ( const auto& span : line.spans ) {
RichText::BaselineAlignValue baselineAlign =
effectiveInlineBaselineAlign( inlineItems, span.inlinePath, span.baselineAlign );
if ( baselineAlign.type == RichText::BaselineAlignment::Middle ) {
hasMiddleAlignedSpan = true;
continue;
}
if ( span.type == RichText::RenderSpan::Type::Text ) {
InlineVerticalEdges edges = inlineAncestorLineHeightEdges(
inlineItems, span.inlinePath, span.size.getHeight() );
@@ -1896,6 +1916,18 @@ class RichTextInlineLayouter {
}
}
// Every inline formatting context contains the parent element's zero-width strut.
// A middle-aligned box is positioned relative to that strut's baseline and x-height;
// it does not first establish the baseline from its own fallback bottom edge.
if ( hasMiddleAlignedSpan && defaultStyle.Font ) {
Float fontLineHeight = defaultStyle.Font->getLineSpacing( defaultStyle.CharacterSize );
Float usedLineHeight = std::max( fontLineHeight, forcedLineHeight );
Float halfLeading = std::max( 0.f, usedLineHeight - fontLineHeight ) * 0.5f;
maxAscent = std::max( maxAscent, getParentAscent( defaultStyle ) + halfLeading );
maxDescent = std::max( maxDescent, getParentDescent( defaultStyle ) + halfLeading );
hasParticipatingLineHeight = true;
}
if ( !hasParticipatingLineHeight )
return;
@@ -1954,6 +1986,7 @@ class RichTextInlineLayouter {
metrics.size = payload.size;
metrics.baseline = payload.baseline;
metrics.isLineBreak = payload.isLineBreak;
metrics.forceEmptyLine = payload.forceEmptyLine;
metrics.isBlock = payload.isBlock;
metrics.isBlockFormattingContext = payload.isBlockFormattingContext;
metrics.floatType = payload.floatType;
@@ -2197,17 +2230,18 @@ void RichText::addDrawable( std::shared_ptr<Drawable> drawable ) {
invalidateLayout();
}
void RichText::addCustomSize(
const Sizef& size, InlineFloat floatType, InlineClear clearType, Float baseline,
const BaselineAlignValue& baselineAlign, InlineSource source, bool isBlock,
bool isBlockFormattingContext,
std::shared_ptr<const std::vector<FloatExclusion>> propagatedFloats ) {
void RichText::addCustomSize( const Sizef& size, InlineFloat floatType, InlineClear clearType,
Float baseline, const BaselineAlignValue& baselineAlign,
InlineSource source, bool isBlock, bool isBlockFormattingContext,
std::shared_ptr<const std::vector<FloatExclusion>> propagatedFloats,
const Rectf& margin ) {
Float usedBaseline = baseline >= 0.f ? baseline : size.getHeight();
InlineItem item;
InlineItem::AtomicBox box;
box.source = source;
box.size = size;
box.formattingMargin = margin;
box.baseline = usedBaseline;
box.floatType = floatType;
box.clearType = clearType;
@@ -2221,10 +2255,12 @@ void RichText::addCustomSize(
invalidateLayout();
}
void RichText::addLineBreak() {
void RichText::addLineBreak( bool forceEmptyLine, Float lineHeight ) {
InlineItem item;
InlineItem::AtomicBox box;
box.isLineBreak = true;
box.forceEmptyLine = forceEmptyLine;
box.size.setHeight( lineHeight );
item.data = std::move( box );
resolveInlinePath( mInlineItems, mInlinePath )->push_back( std::move( item ) );
+9 -4
View File
@@ -436,14 +436,18 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) {
return valid;
};
auto getAtomicWidgetFragmentBounds = [&]( UIWidget* widget, Rectf& outBounds ) {
auto getAtomicWidgetFragmentBounds = [&]( UIWidget* widget, Rectf& outBounds,
Rectf* formattingMargin ) {
auto it = mWidgetFragments.find( widget );
if ( it == mWidgetFragments.end() )
return false;
bool valid = false;
for ( const auto* fragment : it->second.atomicBoxes )
for ( const auto* fragment : it->second.atomicBoxes ) {
expandBounds( outBounds, valid, toContainerBounds( fragment->bounds ) );
if ( formattingMargin )
*formattingMargin = fragment->formattingMargin;
}
return valid;
};
@@ -618,10 +622,11 @@ void BlockLayouter::positionRichTextChildren( Graphics::RichText* rt ) {
} else {
curCharIdx += 1;
Rectf atomicBounds( maxF, maxF, lowF, lowF );
if ( getAtomicWidgetFragmentBounds( widget, atomicBounds ) ) {
Rectf formattingMargin;
if ( getAtomicWidgetFragmentBounds( widget, atomicBounds, &formattingMargin ) ) {
Rectf margin = UIHTMLWidget::getFormattingContextLayoutPixelsMargin( widget );
Vector2f targetPos( atomicBounds.Left + margin.Left,
atomicBounds.Top + margin.Top );
atomicBounds.Top + formattingMargin.Top );
widget->setPixelsPosition( targetPos - offset );
if ( establishesBlockFormattingContext( widget ) &&
+28
View File
@@ -116,6 +116,34 @@ BorderType Borders::toBorderType( const std::string& borderType ) {
return BorderType::Inside;
}
std::string Borders::fromBorderStyle( BorderStyle borderStyle ) {
switch ( borderStyle ) {
case BorderStyle::None:
return "none";
case BorderStyle::Hidden:
return "hidden";
case BorderStyle::Dotted:
return "dotted";
case BorderStyle::Dashed:
return "dashed";
case BorderStyle::Solid:
default:
return "solid";
}
}
BorderStyle Borders::toBorderStyle( const std::string& borderStyle ) {
if ( borderStyle == "none" )
return BorderStyle::None;
if ( borderStyle == "hidden" )
return BorderStyle::Hidden;
if ( borderStyle == "dotted" )
return BorderStyle::Dotted;
if ( borderStyle == "dashed" )
return BorderStyle::Dashed;
return BorderStyle::Solid;
}
Sizef Borders::radiusFromString( const UINode* node, const std::string& val ) {
auto split = String::split( val, ' ' );
Sizef size;
+32 -24
View File
@@ -416,6 +416,10 @@ void StyleSheetSpecification::registerDefaultProperties() {
registerProperty( "border-bottom-width", "0" )
.setType( PropertyType::NumberLength )
.setRelativeTarget( PropertyRelativeTarget::LocalBlockRadiusWidth );
registerProperty( "border-left-style", "none" ).setType( PropertyType::String );
registerProperty( "border-right-style", "none" ).setType( PropertyType::String );
registerProperty( "border-top-style", "none" ).setType( PropertyType::String );
registerProperty( "border-bottom-style", "none" ).setType( PropertyType::String );
registerProperty( "border-top-left-radius", "0" ).setType( PropertyType::RadiusLength );
registerProperty( "border-top-right-radius", "0" ).setType( PropertyType::RadiusLength );
@@ -574,6 +578,10 @@ void StyleSheetSpecification::registerDefaultProperties() {
"border-width",
{ "border-top-width", "border-right-width", "border-bottom-width", "border-left-width" },
"border-box" );
registerShorthand(
"border-style",
{ "border-top-style", "border-right-style", "border-bottom-style", "border-left-style" },
"border-box" );
registerShorthand( "border-radius",
{ "border-top-left-radius", "border-top-right-radius",
"border-bottom-right-radius", "border-bottom-left-radius" },
@@ -1320,25 +1328,28 @@ void StyleSheetSpecification::registerDefaultShorthandParsers() {
String::valueIndex(
tok, "none;hidden;dotted;dashed;solid;double;groove;ridge;inset;outset" ) ) {
// small hack to at least hide none borders until border-style is implemented
if ( tok == "none" ) {
int pos = getIndexEndingWith( propNames, "-width" );
if ( pos != -1 ) {
const ShorthandDefinition* shorthand = getShorthand( propNames[pos] );
if ( NULL != shorthand ) {
auto bbVec = mShorthandParsers["border-box"]( shorthand, "0" );
for ( auto& bb : bbVec )
properties.emplace_back( bb );
int pos = getIndexEndingWith( propNames, "-style" );
if ( pos != -1 ) {
const ShorthandDefinition* styleShorthand = getShorthand( propNames[pos] );
if ( styleShorthand ) {
auto styleVec = mShorthandParsers["border-box"]( styleShorthand, tok );
for ( auto& styleProp : styleVec )
properties.emplace_back( styleProp );
}
}
if ( tok == "none" || tok == "hidden" ) {
int widthPos = getIndexEndingWith( propNames, "-width" );
if ( widthPos != -1 ) {
const ShorthandDefinition* widthShorthand =
getShorthand( propNames[widthPos] );
if ( widthShorthand ) {
auto widthVec = mShorthandParsers["border-box"]( widthShorthand, "0" );
for ( auto& widthProp : widthVec )
properties.emplace_back( widthProp );
}
}
continue;
}
int pos = getIndexEndingWith( propNames, "-style" );
// boder-style is not implemented yet
if ( pos != -1 )
continue;
} else if ( Color::isColorString( tok ) || String::startsWith( tok, "var(" ) ) {
int pos = getIndexEndingWith( propNames, "-color" );
if ( pos != -1 ) {
@@ -1380,17 +1391,14 @@ void StyleSheetSpecification::registerDefaultShorthandParsers() {
String::valueIndex(
tok, "none;hidden;dotted;dashed;solid;double;groove;ridge;inset;outset" ) ) {
// At least reset the border width if "none" was used
if ( "none" == tok ) {
int pos = getIndexEndingWith( propNames, "-width" );
if ( pos != -1 )
properties.emplace_back( StyleSheetProperty( propNames[pos], "0" ) );
}
// boder-style is not implemented yet
int pos = getIndexEndingWith( propNames, "-style" );
if ( pos != -1 )
continue;
properties.emplace_back( StyleSheetProperty( propNames[pos], tok ) );
if ( tok == "none" || tok == "hidden" ) {
int widthPos = getIndexEndingWith( propNames, "-width" );
if ( widthPos != -1 )
properties.emplace_back( StyleSheetProperty( propNames[widthPos], "0" ) );
}
} else if ( Color::isColorString( tok ) || String::startsWith( tok, "var(" ) ) {
int pos = getIndexEndingWith( propNames, "-color" );
if ( pos != -1 )
+101 -5
View File
@@ -1,4 +1,5 @@
#include <eepp/core.hpp>
#include <eepp/graphics/primitives.hpp>
#include <eepp/graphics/renderer/renderer.hpp>
#include <eepp/graphics/vertexbuffer.hpp>
#include <eepp/ui/uiborderdrawable.hpp>
@@ -57,6 +58,50 @@ void UIBorderDrawable::draw( const Vector2f& position, const Sizef& size ) {
if ( mSmooth && !isPolySmooth )
GLi->polygonSmooth( isPolySmooth );
// Patterned sides currently use axis-aligned primitives and therefore only render correctly
// when all border radii are zero. Rounded dotted/dashed borders need path-aware segments.
// CSS leaves the exact dash/gap algorithm to the user agent. Use one-border-width dot
// segments or three-width dashes with an equal-sized gap, centered along each side so any
// remainder is shared by both ends. Rectangular dot segments intentionally match browser
// rasterization for thin borders, where a nominal round dot resolves to a square pixel.
// TODO: Build and retain the segment geometry during update() instead of regenerating it
// on every draw. A separate cached triangle-list buffer avoids joining disjoint segments
// into the solid border's triangle strip.
auto drawPatternedSide = [&]( const Border& border, bool horizontal, Float fixed,
Float start, Float length ) {
if ( border.width <= 0 ||
( border.style != BorderStyle::Dotted && border.style != BorderStyle::Dashed ) )
return;
const Float segment =
border.style == BorderStyle::Dotted ? border.width : border.width * 3.f;
const Float step = segment * 2.f;
if ( segment <= 0.f || length <= 0.f )
return;
const Uint32 count =
eemax<Uint32>( 1, static_cast<Uint32>( std::ceil( length / step ) ) );
const Float used = ( count - 1 ) * step + segment;
Float cursor = start + eemax( 0.f, ( length - used ) * 0.5f );
Primitives primitive;
primitive.setColor( border.color );
for ( Uint32 i = 0; i < count && cursor < start + length; ++i, cursor += step ) {
const Float end = eemin( cursor + segment, start + length );
if ( horizontal ) {
primitive.drawRectangle( Rectf( cursor, fixed, end, fixed + border.width ) );
} else {
primitive.drawRectangle( Rectf( fixed, cursor, fixed + border.width, end ) );
}
}
};
const Float left = mPosition.x;
const Float top = mPosition.y;
drawPatternedSide( mBorders.top, true, top, left, mSize.getWidth() );
drawPatternedSide( mBorders.bottom, true, top + mSize.getHeight() - mBorders.bottom.width,
left, mSize.getWidth() );
drawPatternedSide( mBorders.left, false, left, top, mSize.getHeight() );
drawPatternedSide( mBorders.right, false, left + mSize.getWidth() - mBorders.right.width,
top, mSize.getHeight() );
}
}
@@ -69,6 +114,12 @@ void UIBorderDrawable::setLineWidth( const Float& width ) {
mBorders.right.width != width || mBorders.bottom.width != width ) {
mBorders.top.width = mBorders.left.width = mBorders.right.width = mBorders.bottom.width =
width;
// setLineWidth() is the legacy programmatic convenience API and historically creates a
// visible solid border. CSS longhand widths use the per-side setters and therefore preserve
// the CSS initial border-style of none until an explicit style is applied.
if ( width > 0 )
mBorders.top.style = mBorders.left.style = mBorders.right.style =
mBorders.bottom.style = BorderStyle::Solid;
mNeedsUpdate = true;
}
}
@@ -194,6 +245,38 @@ void UIBorderDrawable::setBottomWidth( const std::string& bottomWidth ) {
}
}
void UIBorderDrawable::setLeftStyle( const std::string& style ) {
BorderStyle value = Borders::toBorderStyle( style );
if ( mBorders.left.style != value ) {
mBorders.left.style = value;
mNeedsUpdate = true;
}
}
void UIBorderDrawable::setRightStyle( const std::string& style ) {
BorderStyle value = Borders::toBorderStyle( style );
if ( mBorders.right.style != value ) {
mBorders.right.style = value;
mNeedsUpdate = true;
}
}
void UIBorderDrawable::setTopStyle( const std::string& style ) {
BorderStyle value = Borders::toBorderStyle( style );
if ( mBorders.top.style != value ) {
mBorders.top.style = value;
mNeedsUpdate = true;
}
}
void UIBorderDrawable::setBottomStyle( const std::string& style ) {
BorderStyle value = Borders::toBorderStyle( style );
if ( mBorders.bottom.style != value ) {
mBorders.bottom.style = value;
mNeedsUpdate = true;
}
}
void UIBorderDrawable::setTopLeftRadius( const std::string& radius ) {
if ( mBorderStr.radius.topLeft != radius ) {
mBorderStr.radius.topLeft = radius;
@@ -285,6 +368,15 @@ void UIBorderDrawable::setSmooth( bool smooth ) {
void UIBorderDrawable::update() {
updateBorders();
Borders solidBorders = mBorders;
auto hidePatterned = []( Border& border ) {
if ( border.style != BorderStyle::Solid )
border.width = 0;
};
hidePatterned( solidBorders.top );
hidePatterned( solidBorders.right );
hidePatterned( solidBorders.bottom );
hidePatterned( solidBorders.left );
switch ( mBorderType ) {
case BorderType::Outside: {
@@ -307,12 +399,12 @@ void UIBorderDrawable::update() {
size.y += mBorders.bottom.width * 2;
}
Borders::createBorders( mVertexBuffer.get(), mBorders, pos, size );
Borders::createBorders( mVertexBuffer.get(), solidBorders, pos, size );
break;
}
case BorderType::Inside: {
Borders::createBorders( mVertexBuffer.get(), mBorders, Vector2f::Zero, mSize );
Borders::createBorders( mVertexBuffer.get(), solidBorders, Vector2f::Zero, mSize );
break;
}
case BorderType::Outline: {
@@ -335,7 +427,7 @@ void UIBorderDrawable::update() {
size.y += mBorders.bottom.width;
}
Borders::createBorders( mVertexBuffer.get(), mBorders, pos, size );
Borders::createBorders( mVertexBuffer.get(), solidBorders, pos, size );
break;
}
@@ -380,8 +472,12 @@ void UIBorderDrawable::updateBorders() const {
mBorders.radius.bottomRight =
Borders::radiusFromString( mOwner, mBorderStr.radius.bottomRight );
mHasBorder = mBorders.top.width > 0 || mBorders.right.width > 0 || mBorders.bottom.width > 0 ||
mBorders.left.width > 0;
auto visible = []( const Border& border ) {
return border.width > 0 && border.style != BorderStyle::None &&
border.style != BorderStyle::Hidden;
};
mHasBorder = visible( mBorders.top ) || visible( mBorders.right ) ||
visible( mBorders.bottom ) || visible( mBorders.left );
}
}} // namespace EE::UI
+19 -3
View File
@@ -113,6 +113,10 @@ bool UIHTMLInput::applyProperty( const StyleSheetProperty& attribute ) {
PropertyId id = attribute.getPropertyDefinition()->getPropertyId();
switch ( id ) {
case PropertyId::Height:
mHasSpecifiedHeight = !attribute.value().empty() &&
!String::iequals( String::trim( attribute.value() ), "auto" );
break;
case PropertyId::Checked:
case PropertyId::Selected:
mChecked = htmlBoolAttributeIsTrue( attribute );
@@ -186,6 +190,8 @@ void UIHTMLInput::updateLayout() {
// An input is a replaced element. Its anonymous native control must not be processed as a DOM
// child by BlockLayouter, which would restore the control's intrinsic size after flex/grid had
// assigned the host's final used size.
if ( !mHasSpecifiedHeight )
updateHostGeometry();
positionOutOfFlowChildren();
if ( isOutOfFlow() )
updateOutOfFlowPosition();
@@ -334,9 +340,12 @@ void UIHTMLInput::updateHostGeometry() {
if ( getLayoutWidthPolicy() == SizePolicy::WrapContent )
size.setWidth( mChildWidget->getPixelsSize().getWidth() + contentOffset.Left +
contentOffset.Right );
if ( getLayoutHeightPolicy() == SizePolicy::WrapContent )
size.setHeight( mChildWidget->getPixelsSize().getHeight() + contentOffset.Top +
contentOffset.Bottom );
if ( getLayoutHeightPolicy() == SizePolicy::WrapContent || !mHasSpecifiedHeight ) {
Float contentHeight = mChildWidget->getPixelsSize().getHeight();
if ( mChildWidget->isType( UI_TYPE_HTML_TEXTINPUT ) )
contentHeight = mChildWidget->asType<UIHTMLTextInput>()->getIntrinsicContentHeight();
size.setHeight( contentHeight + contentOffset.Top + contentOffset.Bottom );
}
mSyncingGeometry = true;
setPixelsSize( size );
@@ -344,6 +353,13 @@ void UIHTMLInput::updateHostGeometry() {
updateChildGeometry();
}
Float UIHTMLInput::getReplacedElementBaseline() const {
if ( mChildWidget && mChildWidget->isType( UI_TYPE_HTML_TEXTINPUT ) )
return getPixelsContentOffset().Top +
mChildWidget->asType<UIHTMLTextInput>()->getTextBaseline();
return getPixelsSize().getHeight();
}
void UIHTMLInput::updateChildGeometry() {
if ( !mChildWidget || mSyncingGeometry )
return;
+23 -1
View File
@@ -1,3 +1,4 @@
#include <cmath>
#include <eepp/graphics/font.hpp>
#include <eepp/ui/css/propertydefinition.hpp>
#include <eepp/ui/uihtmltextinput.hpp>
@@ -87,11 +88,32 @@ void UIHTMLTextInput::onAutoSize() {
}
}
UITextInput::onAutoSize();
// HTML input hosts own the CSS border and padding and deliberately remove the anonymous
// native control's skin. Size the single editable line from the font's baseline metrics.
// Font line spacing may include a large optional line gap, which is useful for flowing text
// but is not part of a replaced control's intrinsic content height.
if ( mHeightPolicy == SizePolicy::WrapContent && getFont() ) {
setInternalPixelsHeight( getIntrinsicContentHeight() );
} else {
UITextInput::onAutoSize();
}
mPacking = false;
}
Float UIHTMLTextInput::getIntrinsicContentHeight() const {
if ( !getFont() )
return 0.f;
return std::ceil( getFont()->getAscent( getFontSize() ) +
getFont()->getDescent( getFontSize() ) );
}
Float UIHTMLTextInput::getTextBaseline() const {
if ( !getFont() )
return getPixelsSize().getHeight();
return getRealAlignOffset().y + getFont()->getAscent( getFontSize() );
}
Uint32 UIHTMLTextInput::getHtmlSize() const {
return mHtmlSize;
}
+8
View File
@@ -1225,6 +1225,14 @@ UIBorderDrawable* UINode::getBorder() const {
if ( NULL == mBorder ) {
mBorder = UIBorderDrawable::New( this );
mBorder->setColor( Color::Transparent );
if ( mFlags & UI_HTML_ELEMENT ) {
// CSS border-style has an initial value of none. Non-HTML eepp widgets retain the
// framework's legacy solid programmatic-border default.
mBorder->setLeftStyle( "none" );
mBorder->setRightStyle( "none" );
mBorder->setTopStyle( "none" );
mBorder->setBottomStyle( "none" );
}
// mBorder->setLineWidth( PixelDensity::dpToPx( 1 ) );
}
+39 -10
View File
@@ -9,6 +9,7 @@
#include <eepp/ui/css/stylesheetparser.hpp>
#include <eepp/ui/uiborderdrawable.hpp>
#include <eepp/ui/uicodeeditor.hpp>
#include <eepp/ui/uihtmlinput.hpp>
#include <eepp/ui/uilayouter.hpp>
#include <eepp/ui/uinodedrawable.hpp>
#include <eepp/ui/uirichtext.hpp>
@@ -1529,6 +1530,8 @@ static Float getAtomicInlineBoxBaseline( UIWidget* widget, const Sizef& widgetSi
Float fallbackBaseline = widgetSize.getHeight() + margin.Top + margin.Bottom;
if ( !widget->isType( UI_TYPE_HTML_WIDGET ) )
return fallbackBaseline;
if ( widget->isType( UI_TYPE_HTML_INPUT ) )
return margin.Top + widget->asType<UIHTMLInput>()->getReplacedElementBaseline();
auto* htmlWidget = widget->asType<UIHTMLWidget>();
auto* rt = htmlWidget->getRichTextPtr();
@@ -1799,6 +1802,15 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
}
int inlineBoxDepth = 0;
bool hasPreviousNormalFlowBlock = false;
Float previousBlockBottomMargin = 0.f;
auto collapseMargins = []( Float first, Float second ) {
if ( first >= 0.f && second >= 0.f )
return eemax( first, second );
if ( first <= 0.f && second <= 0.f )
return eemin( first, second );
return first + second;
};
auto processNode = [&]( Node* node, auto& processNodeRef ) -> void {
// Helper: walk up through inline ancestors to find the logical prev/next widget
auto findLogicalPrev = []( Node* n ) -> Node* {
@@ -1886,6 +1898,7 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
textNode->setLayoutCharCount( 0 );
return;
}
hasPreviousNormalFlowBlock = false;
textNode->setLayoutCharCount( text.length() );
@@ -2024,8 +2037,11 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
if ( !handled ) {
if ( widget->isType( UI_TYPE_BR ) ) {
richText.addSpan(
"\n", widget->asType<UILineBreak>()->getRichText().getFontStyleConfig() );
const FontStyleConfig& style =
widget->asType<UILineBreak>()->getRichText().getFontStyleConfig();
Float breakLineHeight =
style.Font ? style.Font->getFontHeight( style.CharacterSize ) : 0.f;
richText.addLineBreak( true, breakLineHeight );
lastSpanEndsWithSpace = false;
} else {
Rectf margin = UIHTMLWidget::getFormattingContextLayoutPixelsMargin( widget );
@@ -2141,8 +2157,14 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
w = shrinkWidth;
}
Sizef customSize( w + margin.Left + margin.Right,
size.getHeight() + margin.Top + margin.Bottom );
Rectf formattingMargin = margin;
if ( isNormalFlowBlock && hasPreviousNormalFlowBlock ) {
Float collapsed = collapseMargins( previousBlockBottomMargin, margin.Top );
formattingMargin.Top = collapsed - previousBlockBottomMargin;
}
Sizef customSize( w + formattingMargin.Left + formattingMargin.Right,
size.getHeight() + formattingMargin.Top +
formattingMargin.Bottom );
std::shared_ptr<std::vector<RichText::FloatExclusion>> propagatedFloats;
if ( widget->isType( UI_TYPE_HTML_WIDGET ) ) {
auto* htmlWidget = widget->asType<UIHTMLWidget>();
@@ -2161,12 +2183,19 @@ void UIRichText::rebuildRichText( UILayout* container, RichText& richText, Intri
}
}
}
richText.addCustomSize( customSize, toRichTextFloat( floatType ),
toRichTextClear( clearType ),
getAtomicInlineBoxBaseline( widget, size, margin ),
toRichTextBaselineAlign( getWidgetBaselineAlign( widget ) ),
toRichTextWidgetSource( widget ), isNormalFlowBlock,
isBlockFormattingContext, std::move( propagatedFloats ) );
richText.addCustomSize(
customSize, toRichTextFloat( floatType ), toRichTextClear( clearType ),
getAtomicInlineBoxBaseline( widget, size, margin ),
toRichTextBaselineAlign( getWidgetBaselineAlign( widget ) ),
toRichTextWidgetSource( widget ), isNormalFlowBlock, isBlockFormattingContext,
std::move( propagatedFloats ), formattingMargin );
if ( isNormalFlowBlock ) {
hasPreviousNormalFlowBlock = true;
previousBlockBottomMargin = margin.Bottom;
} else if ( !isFloating ) {
hasPreviousNormalFlowBlock = false;
}
if ( widget->isType( UI_TYPE_TEXTSPAN ) &&
widget->asType<UITextSpan>()->isInlineBlock() &&
+13
View File
@@ -113,6 +113,19 @@ void UITextSpan::draw() {
}
}
void UITextSpan::drawBackground() {
// Inline backgrounds are fragmented and painted by the owning RichText. Painting the node's
// background as well happens after its parent's text and covers the inline foreground.
if ( !isInline() )
UIRichText::drawBackground();
}
void UITextSpan::drawBorder() {
// Inline borders follow the same single-paint-owner rule as inline backgrounds.
if ( !isInline() )
UIRichText::drawBorder();
}
bool UITextSpan::applyProperty( const StyleSheetProperty& attribute ) {
if ( !checkPropertyDefinition( attribute ) )
return false;
+28
View File
@@ -1754,6 +1754,10 @@ std::vector<PropertyId> UIWidget::getPropertiesImplemented() const {
PropertyId::BorderTopWidth,
PropertyId::BorderRightWidth,
PropertyId::BorderBottomWidth,
PropertyId::BorderLeftStyle,
PropertyId::BorderTopStyle,
PropertyId::BorderRightStyle,
PropertyId::BorderBottomStyle,
PropertyId::BorderTopLeftRadius,
PropertyId::BorderTopRightRadius,
PropertyId::BorderBottomLeftRadius,
@@ -1910,6 +1914,14 @@ std::string UIWidget::getPropertyString( const PropertyDefinition* propertyDef,
return String::fromFloat( setBorderEnabled( true )->getBorders().top.width, "px" );
case PropertyId::BorderBottomWidth:
return String::fromFloat( setBorderEnabled( true )->getBorders().bottom.width, "px" );
case PropertyId::BorderLeftStyle:
return Borders::fromBorderStyle( setBorderEnabled( true )->getBorders().left.style );
case PropertyId::BorderRightStyle:
return Borders::fromBorderStyle( setBorderEnabled( true )->getBorders().right.style );
case PropertyId::BorderTopStyle:
return Borders::fromBorderStyle( setBorderEnabled( true )->getBorders().top.style );
case PropertyId::BorderBottomStyle:
return Borders::fromBorderStyle( setBorderEnabled( true )->getBorders().bottom.style );
case PropertyId::BorderTopLeftRadius:
return String::format(
"%s %s",
@@ -2486,6 +2498,22 @@ bool UIWidget::applyProperty( const StyleSheetProperty& attribute ) {
setBorderEnabled( true )->setBottomWidth( attribute.value() );
invalidateDraw();
break;
case PropertyId::BorderLeftStyle:
setBorderEnabled( true )->setLeftStyle( attribute.value() );
invalidateDraw();
break;
case PropertyId::BorderRightStyle:
setBorderEnabled( true )->setRightStyle( attribute.value() );
invalidateDraw();
break;
case PropertyId::BorderTopStyle:
setBorderEnabled( true )->setTopStyle( attribute.value() );
invalidateDraw();
break;
case PropertyId::BorderBottomStyle:
setBorderEnabled( true )->setBottomStyle( attribute.value() );
invalidateDraw();
break;
case PropertyId::BorderTopLeftRadius:
setTopLeftRadius( attribute.value() );
break;
+4 -2
View File
@@ -81,7 +81,7 @@ h6 { font-size: 0.67em; font-weight: bold; margin: 2.33em 0; }
p { margin: 1em 0; }
pre { margin: 1em 0; white-space: pre; }
blockquote { margin: 1em 0; }
hr { display: block; border-width: 1px; border-color: gray; margin: 0.5em 0; min-height: 2px; }
hr { display: block; border-width: 1px; border-style: solid; border-color: gray; margin: 0.5em 0; min-height: 2px; }
ul, ol, dl { margin: 1em 0; }
b, strong { font-weight: bold; }
@@ -103,12 +103,13 @@ dd { margin-left: 40dp; }
summary { cursor: pointer; padding-left: 20dp; list-style-type: disclosure-closed; }
textarea { border-width: 1dp; border-color: #767676; background-color: white; color: black; padding: 2dp; selection-back-color: lightgray; }
textarea { border-width: 1dp; border-style: solid; border-color: #767676; background-color: white; color: black; padding: 2dp; selection-back-color: lightgray; }
input[type="text"],
input[type="password"],
input[type="number"] {
border-width: 1dp;
border-style: solid;
border-color: #767676;
background-color: white;
color: black;
@@ -126,6 +127,7 @@ input[type="reset"] {
display: inline-block;
box-sizing: border-box;
border-width: 1dp;
border-style: solid;
border-color: #767676;
background-color: #f0f0f0;
color: black;
+19 -11
View File
@@ -284,7 +284,11 @@ UTEST( RichText, VerticalAlignAtomicBoxes ) {
RichText::InlineClear::None, 10.f, middleAlign );
middleRt.getSize();
ASSERT_EQ( middleRt.getLines().front().spans.size(), (size_t)2 );
EXPECT_NEAR( middleRt.getLines().front().spans[1].position.y, baselineY, 0.001f );
Float parentXHeight = -font->getGlyph( 'x', 20, false, false ).bounds.Top;
const auto& middleLine = middleRt.getLines().front();
Float expectedMiddleY =
middleLine.maxAscent - parentXHeight * 0.5f - middleLine.spans[1].size.getHeight() * 0.5f;
EXPECT_NEAR( middleLine.spans[1].position.y, expectedMiddleY, 0.001f );
RichText mixedRt;
mixedRt.getFontStyleConfig().Font = font;
@@ -295,7 +299,8 @@ UTEST( RichText, VerticalAlignAtomicBoxes ) {
mixedRt.getSize();
ASSERT_EQ( mixedRt.getLines().front().spans.size(), (size_t)2 );
const auto& mixedLine = mixedRt.getLines().front();
Float expectedMiddleY = mixedLine.maxAscent - 5.f;
expectedMiddleY =
mixedLine.maxAscent - parentXHeight * 0.5f - mixedLine.spans[1].size.getHeight() * 0.5f;
EXPECT_NEAR( mixedLine.spans[1].position.y, expectedMiddleY, 0.001f );
RichText nestedRt;
@@ -318,7 +323,11 @@ UTEST( RichText, VerticalAlignAtomicBoxes ) {
ASSERT_TRUE( nestedOpenParen.text != nullptr );
EXPECT_STRINGEQ( nestedOpenParen.text->getString(), "(" );
EXPECT_EQ( nestedAtomic.type, RichText::RenderSpan::Type::AtomicBox );
EXPECT_NEAR( nestedAtomic.position.y, nestedOpenParen.position.y, 0.5f );
Float nestedParentXHeight = -font->getGlyph( 'x', 16, false, false ).bounds.Top;
Float expectedNestedY = nestedRt.getLines().front().maxAscent - nestedParentXHeight * 0.5f -
nestedAtomic.size.getHeight() * 0.5f;
EXPECT_NEAR( nestedAtomic.position.y, expectedNestedY, 0.001f );
EXPECT_NEAR( nestedAtomic.position.y, nestedOpenParen.position.y, 1.f );
RichText::BaselineAlignValue lengthAlign;
lengthAlign.type = RichText::BaselineAlignment::Length;
@@ -2060,14 +2069,14 @@ UTEST( UIRichText, MarginsTest ) {
// d1 footprint height: 10 + 50 + 30 = 90.
// d2 x = d1 left margin = 5 (its own left margin).
EXPECT_EQ( 5.f, pos2.x );
// d2 y = d1 footprint height (90) + d2 margin top (5) = 95.
EXPECT_EQ( 95.f, pos2.y );
// The adjacent 30px and 5px vertical margins collapse to 30px, so d2 starts at 90px.
EXPECT_EQ( 90.f, pos2.y );
// Check UIRichText bounds
// Width = max(d1 footprint: 110, d2 footprint: 60) = 110.
// Height = sum of line heights: d1 line 90 + d2 line 60 = 150.
// Height = 10 + 50 + collapsed 30 + 50 + trailing 5 = 145.
EXPECT_EQ( 110.f, rt->getPixelsSize().getWidth() );
EXPECT_EQ( 150.f, rt->getPixelsSize().getHeight() );
EXPECT_EQ( 145.f, rt->getPixelsSize().getHeight() );
destroyRichTextScene( sceneNode );
}
@@ -2085,7 +2094,7 @@ UTEST( UIRichText, ForcedLineBreak ) {
sceneNode->update( Time::Zero );
const auto& richText = rt->getRichText();
EXPECT_EQ( richText.getLines().size(), (size_t)3 );
EXPECT_EQ( richText.getLines().size(), (size_t)2 );
destroyRichTextScene( sceneNode );
}
@@ -2104,11 +2113,10 @@ UTEST( UIRichText, CustomBRHeight ) {
const auto& richText = rt->getRichText();
const auto& lines = richText.getLines();
EXPECT_EQ( lines.size(), (size_t)3 );
EXPECT_EQ( lines.size(), (size_t)2 );
if ( lines.size() >= 2 ) {
EXPECT_GT( lines[0].height, lines[1].height );
EXPECT_GT( lines[2].height, 0.f );
EXPECT_GT( lines[1].height, 0.f );
}
destroyRichTextScene( sceneNode );
+3 -1
View File
@@ -953,8 +953,10 @@ UTEST( UIHTMLFloat, autoWidthFloatsShrinkToFitRedditVoteColumn ) {
const Vector2f flairPos = flair->convertToWorldSpace( { 0, 0 } );
const Vector2f emojiPos = emoji->convertToWorldSpace( { 0, 0 } );
EXPECT_GE( emojiPos.y, flairPos.y - 0.01f );
// Font metrics can leave a subpixel overflow for CSS vertical-align: middle (Chromium does
// too); one physical pixel keeps this a containment check rather than an exact-raster check.
EXPECT_LE( emojiPos.y + emoji->getPixelsSize().getHeight(),
flairPos.y + flair->getPixelsSize().getHeight() + 0.01f );
flairPos.y + flair->getPixelsSize().getHeight() + 1.f );
auto* window = Engine::instance()->getCurrentWindow();
window->setClearColor( Color::White );
+139 -8
View File
@@ -20,6 +20,7 @@
#include <eepp/ui/tools/htmlformatter.hpp>
#include <eepp/ui/tools/uiwidgetinspector.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uiborderdrawable.hpp>
#include <eepp/ui/uicheckbox.hpp>
#include <eepp/ui/uicodeeditor.hpp>
#include <eepp/ui/uihtmldetails.hpp>
@@ -236,11 +237,8 @@ UTEST( UIHTMLTable, complexLayout2 ) {
}
UTEST( UIHTML, redditOldThreadWebViewSmoke ) {
if ( std::getenv( "EEPP_REDDIT_OLD_THREAD_VISUAL" ) == nullptr )
UTEST_SKIP( "set EEPP_REDDIT_OLD_THREAD_VISUAL=1 to render the old Reddit fixture" );
auto win = Engine::instance()->createWindow(
WindowSettings( 1024, 1000, "Old Reddit Thread Test", WindowStyle::Default,
WindowSettings( 1024, 1400, "Old Reddit Thread Test", VisualTestWindowStyle,
WindowBackend::Default, 32, {}, 1, false, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
@@ -315,6 +313,12 @@ UTEST( UIHTML, redditOldThreadWebViewSmoke ) {
auto commentButtons =
documentRoot->querySelector( "#thing_t1_on791mh > .entry > .flat-list.buttons" );
auto redditFooter = documentRoot->querySelector( ".footer-parent > .footer" );
auto fancyToggle = documentRoot->querySelector( ".titlebox .fancy-toggle-button" );
auto leaveButton =
documentRoot->querySelector( ".titlebox .fancy-toggle-button .option.active.remove" );
auto searchText = documentRoot->querySelector( "#search input[type=text]" );
auto searchSubmit = documentRoot->querySelector( "#search input[type=submit]" );
auto shortlinkText = documentRoot->find( "shortlink-text" );
ASSERT_TRUE( side != nullptr );
ASSERT_TRUE( siteTable != nullptr );
@@ -347,6 +351,25 @@ UTEST( UIHTML, redditOldThreadWebViewSmoke ) {
ASSERT_TRUE( flairCheckbox != nullptr );
ASSERT_TRUE( commentButtons != nullptr );
ASSERT_TRUE( redditFooter != nullptr );
ASSERT_TRUE( fancyToggle != nullptr );
ASSERT_TRUE( leaveButton != nullptr );
ASSERT_TRUE( searchText != nullptr );
ASSERT_TRUE( searchSubmit != nullptr );
ASSERT_TRUE( shortlinkText != nullptr );
EXPECT_LT( shortlinkText->asType<UIWidget>()->getPixelsSize().getHeight(), 30.f );
EXPECT_EQ( searchText->asType<UIHTMLWidget>()->getBaselineAlign().type,
CSSBaselineAlignment::Middle );
EXPECT_EQ( searchSubmit->asType<UIHTMLWidget>()->getBaselineAlign().type,
CSSBaselineAlignment::Middle );
EXPECT_STRINGEQ( leaveButton->asType<UITextSpan>()->getText(), "leave" );
EXPECT_GT( fancyToggle->getPixelsSize().getWidth(), 0.f );
EXPECT_GT( fancyToggle->getPixelsSize().getHeight(), 0.f );
const Vector2f searchTextWorld =
searchText->asType<UIWidget>()->convertToWorldSpace( Vector2f::Zero );
const Vector2f searchSubmitWorld =
searchSubmit->asType<UIWidget>()->convertToWorldSpace( Vector2f::Zero );
EXPECT_NEAR( searchTextWorld.y + searchText->getPixelsSize().getHeight() * 0.5f,
searchSubmitWorld.y + searchSubmit->getPixelsSize().getHeight() * 0.5f, 1.f );
EXPECT_EQ( redditFooter->asType<UIHTMLWidget>()->getDisplay(), CSSDisplay::Flex );
Float footerColumnRight = 0.f;
for ( auto* col : redditFooter->findAllByClass( "col" ) ) {
@@ -556,10 +579,7 @@ UTEST( UIHTML, redditOldThreadWebViewSmoke ) {
EXPECT_NEAR( arrowBackgroundLayer->getOffset().x, -42.f, 0.1f );
EXPECT_NEAR( arrowBackgroundLayer->getOffset().y, -1678.f, 0.1f );
if ( !FileSystem::fileExists( "output" ) )
FileSystem::makeDir( "output" );
win->getFrontBufferImage().saveToFile( "output/eepp-reddit-old-thread-current.webp",
Image::SaveType::WEBP );
compareImages( utest_state, utest_result, win, "eepp-ui-reddit-old-thread", "html" );
Engine::destroySingleton();
}
@@ -1560,6 +1580,41 @@ UTEST( UIHTMLInput, sizeAttribute ) {
Engine::destroySingleton();
}
UTEST( UIHTML, adjacentBlockMarginsCollapse ) {
init_ui_test();
auto* sceneNode = SceneManager::instance()->getUISceneNode();
sceneNode->loadLayoutFromString( R"html(
<html><body>
<div id="container" style="width: 300px; height: wrap-content; padding: 0;">
<p id="p1" style="font-size: 10px; line-height: 20px; margin: 6px 0;">one</p>
<p id="p2" style="font-size: 10px; line-height: 20px; margin: 6px 0;">two</p>
<p id="p3" style="font-size: 10px; line-height: 20px; margin: 6px 0;">three</p>
</div>
</body></html>
)html" );
sceneNode->updateDirtyLayouts();
auto* container = sceneNode->getRoot()->find( "container" )->asType<UIWidget>();
auto* p1 = sceneNode->getRoot()->find( "p1" )->asType<UIWidget>();
auto* p2 = sceneNode->getRoot()->find( "p2" )->asType<UIWidget>();
auto* p3 = sceneNode->getRoot()->find( "p3" )->asType<UIWidget>();
ASSERT_TRUE( container != nullptr );
ASSERT_TRUE( p1 != nullptr );
ASSERT_TRUE( p2 != nullptr );
ASSERT_TRUE( p3 != nullptr );
EXPECT_NEAR( p1->getPixelsPosition().y, 6.f, 0.5f );
EXPECT_NEAR( p2->getPixelsPosition().y -
( p1->getPixelsPosition().y + p1->getPixelsSize().getHeight() ),
6.f, 0.5f );
EXPECT_NEAR( p3->getPixelsPosition().y -
( p2->getPixelsPosition().y + p2->getPixelsSize().getHeight() ),
6.f, 0.5f );
EXPECT_NEAR( p3->getPixelsPosition().y + p3->getPixelsSize().getHeight(), 78.f, 0.5f );
Engine::destroySingleton();
}
UTEST( UIHTML, DataProperties ) {
init_ui_test();
auto* sceneNode = SceneManager::instance()->getUISceneNode();
@@ -2038,6 +2093,48 @@ UTEST( UIHTMLInput, hostOwnsCSSBox ) {
Engine::destroySingleton();
}
UTEST( UIHTMLInput, inlineTextUsesReplacedControlBaselineAndFontMetrics ) {
init_ui_test();
auto* sceneNode = SceneManager::instance()->getUISceneNode();
sceneNode->loadLayoutFromString( R"html(
<div id="line" style="font-size: 14px; line-height: 20px;">
<span id="label">label</span><input id="input" type="text"
style="font-size: 14px; border: 1px solid gray; padding: 3px 2px;" />
</div>
)html" );
sceneNode->updateDirtyLayouts();
auto* line = sceneNode->getRoot()->find( "line" )->asType<UIHTMLWidget>();
auto* label = sceneNode->getRoot()->find( "label" )->asType<UITextSpan>();
auto* input = sceneNode->getRoot()->find( "input" )->asType<UIHTMLInput>();
ASSERT_TRUE( line != nullptr );
ASSERT_TRUE( label != nullptr );
ASSERT_TRUE( input != nullptr );
ASSERT_TRUE( input->getChildWidget()->isType( UI_TYPE_HTML_TEXTINPUT ) );
auto* textInput = input->getChildWidget()->asType<UIHTMLTextInput>();
ASSERT_TRUE( textInput->getFont() != nullptr );
const Float expectedContentHeight =
std::ceil( textInput->getFont()->getAscent( textInput->getFontSize() ) +
textInput->getFont()->getDescent( textInput->getFontSize() ) );
EXPECT_NEAR( textInput->getPixelsSize().getHeight(), expectedContentHeight, 0.01f );
EXPECT_NEAR( input->getPixelsSize().getHeight(),
expectedContentHeight + input->getPixelsContentOffset().Top +
input->getPixelsContentOffset().Bottom,
0.01f );
const auto& lines = line->getRichTextPtr()->getLines();
ASSERT_EQ( lines.size(), (size_t)1 );
const Float lineBaseline = line->convertToWorldSpace( Vector2f::Zero ).y +
line->getPixelsContentOffset().Top + lines.front().y +
lines.front().maxAscent;
const Float inputBaseline =
input->convertToWorldSpace( Vector2f::Zero ).y + input->getReplacedElementBaseline();
EXPECT_NEAR( inputBaseline, lineBaseline, 0.5f );
Engine::destroySingleton();
}
UTEST( UIHTMLInput, dynamicTypeKeepsHostAndState ) {
init_ui_test();
auto* input = UIHTMLInput::New();
@@ -2675,6 +2772,40 @@ UTEST( UIBorder, renderingVariations ) {
Engine::destroySingleton();
}
UTEST( UIBorder, cssSideStyles ) {
init_ui_test();
auto* sceneNode = SceneManager::instance()->getUISceneNode();
sceneNode->loadLayoutFromString( R"html(
<html><body>
<div id="styled" style="width: 100px; height: 40px;
border-top: 1px dotted gray; border-right: 2px dashed red;
border-bottom: 3px solid blue; border-left: none;"></div>
<div id="initial" style="width: 100px; height: 40px; border-top-width: 5px;"></div>
</body></html>
)html" );
sceneNode->updateDirtyLayouts();
auto* styled = sceneNode->getRoot()->find( "styled" )->asType<UIWidget>();
ASSERT_TRUE( styled != nullptr );
const Borders& borders = styled->getBorder()->getBorders();
EXPECT_EQ( borders.top.style, BorderStyle::Dotted );
EXPECT_EQ( borders.right.style, BorderStyle::Dashed );
EXPECT_EQ( borders.bottom.style, BorderStyle::Solid );
EXPECT_EQ( borders.left.style, BorderStyle::None );
EXPECT_EQ( borders.top.width, 1 );
EXPECT_EQ( borders.right.width, 2 );
EXPECT_EQ( borders.bottom.width, 3 );
EXPECT_EQ( borders.left.width, 0 );
auto* initial = sceneNode->getRoot()->find( "initial" )->asType<UIWidget>();
ASSERT_TRUE( initial != nullptr );
const Borders& initialBorders = initial->getBorder()->getBorders();
EXPECT_EQ( initialBorders.top.style, BorderStyle::None );
EXPECT_EQ( initialBorders.top.width, 5 );
Engine::destroySingleton();
}
UTEST( UIBorder, renderingVariations2 ) {
auto win = Engine::instance()->createWindow(
WindowSettings( 1024, 653, "Border Rendering Test 2", VisualTestWindowStyle,
+2
View File
@@ -408,6 +408,8 @@ void App::registerKeyBindings() {
if ( texture )
texture->setFilter( mTextureFilter );
}
mImageViewer->invalidateDraw();
}
} else if ( key == KEY_L ) {
mImageViewer->setPreserveImageView( !mImageViewer->getPreserveImageView() );