diff --git a/bin/unit_tests/assets/html/lobsters_item.html b/bin/unit_tests/assets/html/lobsters_item.html new file mode 100644 index 000000000..d29921a57 --- /dev/null +++ b/bin/unit_tests/assets/html/lobsters_item.html @@ -0,0 +1,158 @@ + + +Lobsters + + + + +
+
    +
  1. +
    +
    + +
    +
    + +
  2. +
+
diff --git a/include/eepp/scene/event.hpp b/include/eepp/scene/event.hpp index ddaa5d86d..cc0bf7caf 100644 --- a/include/eepp/scene/event.hpp +++ b/include/eepp/scene/event.hpp @@ -127,6 +127,7 @@ class EE_API Event { OnNavigationCompleted, OnNavigationError, OnTitleChanged, + OnToggle, NoEvent = eeINDEX_NOT_FOUND }; diff --git a/include/eepp/ui.hpp b/include/eepp/ui.hpp index 6a010b4e4..908beb8f6 100644 --- a/include/eepp/ui.hpp +++ b/include/eepp/ui.hpp @@ -93,6 +93,7 @@ #include #include #include +#include #include #include #include diff --git a/include/eepp/ui/css/propertydefinition.hpp b/include/eepp/ui/css/propertydefinition.hpp index 3516a90f3..3790b1b48 100644 --- a/include/eepp/ui/css/propertydefinition.hpp +++ b/include/eepp/ui/css/propertydefinition.hpp @@ -251,6 +251,7 @@ enum class PropertyId : Uint32 { Cols = String::hash( "cols" ), InputMode = String::hash( "input-mode" ), Hidden = String::hash( "hidden" ), + Open = String::hash( "open" ), Display = String::hash( "display" ), Position = String::hash( "position" ), Top = String::hash( "top" ), diff --git a/include/eepp/ui/csslayouttypes.hpp b/include/eepp/ui/csslayouttypes.hpp index 0933e83c7..f9a90debe 100644 --- a/include/eepp/ui/csslayouttypes.hpp +++ b/include/eepp/ui/csslayouttypes.hpp @@ -44,7 +44,9 @@ enum class CSSListStyleType { LowerAlpha, UpperAlpha, LowerRoman, - UpperRoman + UpperRoman, + DisclosureClosed, + DisclosureOpen }; struct EE_API CSSListStyleTypeHelper { diff --git a/include/eepp/ui/uihelper.hpp b/include/eepp/ui/uihelper.hpp index 15e1d31ed..8472c26c7 100644 --- a/include/eepp/ui/uihelper.hpp +++ b/include/eepp/ui/uihelper.hpp @@ -132,6 +132,8 @@ enum UINodeType { UI_TYPE_HTML_LIST_ITEM, UI_TYPE_HTML_IMAGE, UI_TYPE_HTML_FORM, + UI_TYPE_HTML_DETAILS, + UI_TYPE_HTML_SUMMARY, UI_TYPE_WEBVIEW, UI_TYPE_SVG, UI_TYPE_TEXTNODE, diff --git a/include/eepp/ui/uihtmldetails.hpp b/include/eepp/ui/uihtmldetails.hpp new file mode 100644 index 000000000..c5c7ea6de --- /dev/null +++ b/include/eepp/ui/uihtmldetails.hpp @@ -0,0 +1,110 @@ +#ifndef EE_UI_UIHTMLDETAILS_HPP +#define EE_UI_UIHTMLDETAILS_HPP + +#include +#include +#include + +namespace EE { namespace UI { + +class UIHTMLSummary; + +class EE_API UIHTMLDetails : public UIRichText { + public: + static UIHTMLDetails* New(); + + virtual Uint32 getType() const override; + + virtual bool isType( const Uint32& type ) const override; + + virtual void loadFromXmlNode( const pugi::xml_node& node ) override; + + virtual bool applyProperty( const StyleSheetProperty& attribute ) override; + + virtual std::string getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex = 0 ) const override; + + virtual std::vector getPropertiesImplemented() const override; + + virtual void updateLayout() override; + + bool isOpen() const; + + void setOpen( bool open ); + + UIHTMLSummary* findSummaryChild() const; + + bool isActiveSummary( const UIHTMLSummary* summary ) const; + + void markSummaryMarkersDirty(); + + protected: + bool mOpen{ false }; + UIHTMLSummary* mAutoSummary{ nullptr }; + UnorderedMap mForcedHiddenChildren; + bool mSyncingDetailsChildren{ false }; + + UIHTMLDetails(); + + void ensureAutoSummary(); + + void syncDetailsChildrenVisibility(); + + virtual void onChildCountChange( Node* child, const bool& removed ) override; +}; + +class EE_API UIHTMLSummary : public UIRichText { + public: + static UIHTMLSummary* New(); + + virtual Uint32 getType() const override; + + virtual bool isType( const Uint32& type ) const override; + + virtual void draw() override; + + virtual Uint32 onMouseClick( const Vector2i& position, const Uint32& flags ) override; + + virtual Uint32 onKeyDown( const KeyEvent& event ) override; + + void setDisclosureMarkerDirty(); + + CSSListStyleType getListStyleType() const { return mListStyleType; } + + void setListStyleType( CSSListStyleType type ); + + virtual bool applyProperty( const StyleSheetProperty& attribute ) override; + + virtual std::string getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex = 0 ) const override; + + virtual std::vector getPropertiesImplemented() const override; + + UIHTMLDetails* findParentDetails() const; + + bool toggleParentDetails(); + + protected: + CSSListStyleType mListStyleType{ CSSListStyleType::DisclosureClosed }; + std::unique_ptr mListMarkerText; + bool mUsingDefaultListStylePadding{ true }; + bool mApplyingDefaultListStylePadding{ false }; + + UIHTMLSummary(); + + CSSListStyleType getDisclosureMarkerType() const; + + int countPrecedingSummarySiblings() const; + + void invalidateListMarker(); + + void syncDefaultListStylePadding(); + + virtual void onFontChanged() override; + + virtual void onFontStyleChanged() override; +}; + +}} // namespace EE::UI + +#endif diff --git a/include/eepp/ui/uihtmlliststyle.hpp b/include/eepp/ui/uihtmlliststyle.hpp new file mode 100644 index 000000000..f26cc9773 --- /dev/null +++ b/include/eepp/ui/uihtmlliststyle.hpp @@ -0,0 +1,26 @@ +#ifndef EE_UI_UIHTMLLISTSTYLE_HPP +#define EE_UI_UIHTMLLISTSTYLE_HPP + +#include +#include +#include +#include + +namespace EE { namespace UI { + +class EE_API UIHTMLListStyle { + public: + static bool isPrimitiveMarker( CSSListStyleType type ); + + static bool isTextMarker( CSSListStyleType type ); + + static String getTextMarkerString( CSSListStyleType type, int index ); + + static void drawPrimitiveMarker( CSSListStyleType type, const Vector2f& screenPos, + const Rectf& paddingPx, + const Graphics::FontStyleConfig& style ); +}; + +}} // namespace EE::UI + +#endif diff --git a/src/eepp/graphics/csslayouttypes.cpp b/src/eepp/graphics/csslayouttypes.cpp index 3a3e92253..804f25063 100644 --- a/src/eepp/graphics/csslayouttypes.cpp +++ b/src/eepp/graphics/csslayouttypes.cpp @@ -107,6 +107,10 @@ std::string CSSListStyleTypeHelper::toString( CSSListStyleType type ) { return "lower-roman"; case CSSListStyleType::UpperRoman: return "upper-roman"; + case CSSListStyleType::DisclosureClosed: + return "disclosure-closed"; + case CSSListStyleType::DisclosureOpen: + return "disclosure-open"; case CSSListStyleType::None: default: return "none"; @@ -130,6 +134,10 @@ CSSListStyleType CSSListStyleTypeHelper::fromString( std::string_view val ) { return CSSListStyleType::LowerRoman; if ( val == "upper-roman" ) return CSSListStyleType::UpperRoman; + if ( val == "disclosure-closed" ) + return CSSListStyleType::DisclosureClosed; + if ( val == "disclosure-open" ) + return CSSListStyleType::DisclosureOpen; return CSSListStyleType::None; } diff --git a/src/eepp/ui/css/stylesheetspecification.cpp b/src/eepp/ui/css/stylesheetspecification.cpp index dcd00ed00..f9b08137d 100644 --- a/src/eepp/ui/css/stylesheetspecification.cpp +++ b/src/eepp/ui/css/stylesheetspecification.cpp @@ -445,6 +445,7 @@ void StyleSheetSpecification::registerDefaultProperties() { registerProperty( "input-mode", "normal" ).setType( PropertyType::String ); registerProperty( "hidden", "" ).setType( PropertyType::Bool ); + registerProperty( "open", "" ).setType( PropertyType::Bool ); registerProperty( "display", "inline" ).setType( PropertyType::String ); registerProperty( "position", "static" ).setType( PropertyType::String ); registerProperty( "float", "none" ).setType( PropertyType::String ); diff --git a/src/eepp/ui/uihtmldetails.cpp b/src/eepp/ui/uihtmldetails.cpp new file mode 100644 index 000000000..febe6fd3e --- /dev/null +++ b/src/eepp/ui/uihtmldetails.cpp @@ -0,0 +1,381 @@ +#include +#include +#include +#include +#include +#include + +namespace EE { namespace UI { + +static bool htmlBoolAttributeIsTrue( const StyleSheetProperty& property ) { + if ( property.value().empty() ) + return true; + + std::string value( property.value() ); + String::toLowerInPlace( value ); + if ( value == property.getName() ) + return true; + if ( value == "false" || value == "0" || value == "no" ) + return false; + return property.asBool(); +} + +UIHTMLDetails* UIHTMLDetails::New() { + return eeNew( UIHTMLDetails, () ); +} + +UIHTMLDetails::UIHTMLDetails() : UIRichText( "details" ) {} + +Uint32 UIHTMLDetails::getType() const { + return UI_TYPE_HTML_DETAILS; +} + +bool UIHTMLDetails::isType( const Uint32& type ) const { + return UIHTMLDetails::getType() == type ? true : UIRichText::isType( type ); +} + +void UIHTMLDetails::loadFromXmlNode( const pugi::xml_node& node ) { + UIRichText::loadFromXmlNode( node ); + syncDetailsChildrenVisibility(); +} + +bool UIHTMLDetails::applyProperty( const StyleSheetProperty& attribute ) { + if ( !checkPropertyDefinition( attribute ) ) + return false; + + if ( attribute.getPropertyDefinition()->getPropertyId() == PropertyId::Open ) { + setOpen( htmlBoolAttributeIsTrue( attribute ) ); + return true; + } + + return UIRichText::applyProperty( attribute ); +} + +std::string UIHTMLDetails::getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex ) const { + if ( NULL == propertyDef ) + return ""; + + switch ( propertyDef->getPropertyId() ) { + case PropertyId::Open: + return mOpen ? "true" : "false"; + default: + return UIRichText::getPropertyString( propertyDef, propertyIndex ); + } +} + +std::vector UIHTMLDetails::getPropertiesImplemented() const { + auto props = UIRichText::getPropertiesImplemented(); + props.push_back( PropertyId::Open ); + return props; +} + +void UIHTMLDetails::updateLayout() { + syncDetailsChildrenVisibility(); + UIRichText::updateLayout(); +} + +bool UIHTMLDetails::isOpen() const { + return mOpen; +} + +void UIHTMLDetails::setOpen( bool open ) { + if ( mOpen == open ) + return; + + mOpen = open; + syncDetailsChildrenVisibility(); + markSummaryMarkersDirty(); + sendCommonEvent( Event::OnToggle ); + setLayoutDirty(); + invalidateDraw(); +} + +UIHTMLSummary* UIHTMLDetails::findSummaryChild() const { + Node* child = getFirstChild(); + while ( child ) { + if ( child->isType( UI_TYPE_HTML_SUMMARY ) ) { + auto* summary = child->asType(); + if ( summary != mAutoSummary ) + return summary; + } + child = child->getNextNode(); + } + + return mAutoSummary && mAutoSummary->getParent() == this ? mAutoSummary : nullptr; +} + +bool UIHTMLDetails::isActiveSummary( const UIHTMLSummary* summary ) const { + return summary != nullptr && findSummaryChild() == summary; +} + +void UIHTMLDetails::markSummaryMarkersDirty() { + Node* child = getFirstChild(); + while ( child ) { + if ( child->isType( UI_TYPE_HTML_SUMMARY ) ) + child->asType()->setDisclosureMarkerDirty(); + child = child->getNextNode(); + } +} + +void UIHTMLDetails::ensureAutoSummary() { + Node* child = getFirstChild(); + while ( child ) { + if ( child->isType( UI_TYPE_HTML_SUMMARY ) && child != mAutoSummary ) + return; + child = child->getNextNode(); + } + + if ( mAutoSummary && mAutoSummary->getParent() == this ) + return; + + mAutoSummary = UIHTMLSummary::New(); + mAutoSummary->setParent( this ); + mAutoSummary->toBack(); + + auto* text = UITextNode::New(); + text->setText( "Details" ); + text->setParent( mAutoSummary ); +} + +void UIHTMLDetails::syncDetailsChildrenVisibility() { + if ( mSyncingDetailsChildren ) + return; + + mSyncingDetailsChildren = true; + ensureAutoSummary(); + UIHTMLSummary* activeSummary = findSummaryChild(); + + for ( auto it = mForcedHiddenChildren.begin(); it != mForcedHiddenChildren.end(); ) { + if ( it->first->getParent() != this ) + it = mForcedHiddenChildren.erase( it ); + else + ++it; + } + + Node* child = getFirstChild(); + while ( child ) { + Node* next = child->getNextNode(); + if ( child == mAutoSummary && activeSummary != mAutoSummary ) { + mForcedHiddenChildren.erase( child ); + child->setVisible( false ); + } else if ( child == activeSummary ) { + auto forcedIt = mForcedHiddenChildren.find( child ); + if ( forcedIt != mForcedHiddenChildren.end() ) { + child->setVisible( forcedIt->second ); + mForcedHiddenChildren.erase( forcedIt ); + } else if ( !child->isVisible() ) { + child->setVisible( true ); + } + } else if ( mOpen ) { + auto forcedIt = mForcedHiddenChildren.find( child ); + if ( forcedIt != mForcedHiddenChildren.end() ) { + child->setVisible( forcedIt->second ); + mForcedHiddenChildren.erase( forcedIt ); + } + } else { + if ( mForcedHiddenChildren.find( child ) == mForcedHiddenChildren.end() ) + mForcedHiddenChildren[child] = child->isVisible(); + child->setVisible( false ); + } + child = next; + } + mSyncingDetailsChildren = false; +} + +void UIHTMLDetails::onChildCountChange( Node* child, const bool& removed ) { + if ( removed ) { + mForcedHiddenChildren.erase( child ); + if ( child == mAutoSummary && !mSyncingDetailsChildren ) + mAutoSummary = nullptr; + } + + UIRichText::onChildCountChange( child, removed ); + if ( !mSyncingDetailsChildren ) + syncDetailsChildrenVisibility(); +} + +UIHTMLSummary* UIHTMLSummary::New() { + return eeNew( UIHTMLSummary, () ); +} + +UIHTMLSummary::UIHTMLSummary() : UIRichText( "summary" ) { + mDisplay = CSSDisplay::ListItem; + setFlags( UI_CREATING_NODE ); + applyProperty( StyleSheetProperty( "cursor", "pointer" ) ); + applyProperty( StyleSheetProperty( "padding-left", "20dp" ) ); + applyProperty( StyleSheetProperty( "list-style-type", "disclosure-closed" ) ); + getUIStyle()->setStyleSheetProperty( StyleSheetProperty( "cursor", "pointer" ) ); + getUIStyle()->setStyleSheetProperty( + StyleSheetProperty( "list-style-type", "disclosure-closed" ) ); + unsetFlags( UI_CREATING_NODE ); + setFlags( UI_TAB_FOCUSABLE ); +} + +Uint32 UIHTMLSummary::getType() const { + return UI_TYPE_HTML_SUMMARY; +} + +bool UIHTMLSummary::isType( const Uint32& type ) const { + return UIHTMLSummary::getType() == type ? true : UIRichText::isType( type ); +} + +void UIHTMLSummary::draw() { + UIRichText::draw(); + if ( mVisible && 0.f != mAlpha ) { + const CSSListStyleType markerType = getDisclosureMarkerType(); + if ( UIHTMLListStyle::isPrimitiveMarker( markerType ) ) { + UIHTMLListStyle::drawPrimitiveMarker( markerType, mScreenPos, mPaddingPx, + mRichText.getFontStyleConfig() ); + } else if ( mListMarkerText && !mListMarkerText->getString().empty() ) { + const Float fontSize = mRichText.getFontStyleConfig().CharacterSize; + const Float offset = 0.25f * fontSize; + const Float markerX = + mScreenPos.x + mPaddingPx.Left - mListMarkerText->getTextWidth() - offset; + mListMarkerText->draw( markerX, mScreenPos.y + mPaddingPx.Top, Vector2f::One, 0.f, + getBlendMode() ); + } + } +} + +void UIHTMLSummary::setDisclosureMarkerDirty() { + invalidateDraw(); +} + +UIHTMLDetails* UIHTMLSummary::findParentDetails() const { + Node* parent = getParent(); + while ( parent ) { + if ( parent->isType( UI_TYPE_HTML_DETAILS ) ) + return parent->asType(); + parent = parent->getParent(); + } + return nullptr; +} + +bool UIHTMLSummary::toggleParentDetails() { + UIHTMLDetails* details = findParentDetails(); + if ( details && details->isActiveSummary( this ) ) { + details->setOpen( !details->isOpen() ); + return true; + } + return false; +} + +void UIHTMLSummary::setListStyleType( CSSListStyleType type ) { + if ( mListStyleType != type ) { + mListStyleType = type; + syncDefaultListStylePadding(); + invalidateListMarker(); + } +} + +bool UIHTMLSummary::applyProperty( const StyleSheetProperty& attribute ) { + if ( !checkPropertyDefinition( attribute ) ) + return false; + + switch ( attribute.getPropertyDefinition()->getPropertyId() ) { + case PropertyId::ListStyleType: + setListStyleType( CSSListStyleTypeHelper::fromString( attribute.value() ) ); + return true; + case PropertyId::PaddingLeft: + if ( !isCreatingNode() && !mApplyingDefaultListStylePadding ) + mUsingDefaultListStylePadding = false; + break; + default: + break; + } + + return UIRichText::applyProperty( attribute ); +} + +std::string UIHTMLSummary::getPropertyString( const PropertyDefinition* propertyDef, + const Uint32& propertyIndex ) const { + if ( NULL == propertyDef ) + return ""; + + switch ( propertyDef->getPropertyId() ) { + case PropertyId::ListStyleType: + return CSSListStyleTypeHelper::toString( mListStyleType ); + default: + return UIRichText::getPropertyString( propertyDef, propertyIndex ); + } +} + +std::vector UIHTMLSummary::getPropertiesImplemented() const { + auto props = UIRichText::getPropertiesImplemented(); + props.push_back( PropertyId::ListStyleType ); + return props; +} + +CSSListStyleType UIHTMLSummary::getDisclosureMarkerType() const { + UIHTMLDetails* details = findParentDetails(); + if ( mListStyleType == CSSListStyleType::DisclosureClosed && details && details->isOpen() ) + return CSSListStyleType::DisclosureOpen; + return mListStyleType; +} + +int UIHTMLSummary::countPrecedingSummarySiblings() const { + int count = 0; + Node* prev = getPrevNode(); + while ( prev ) { + if ( prev->isWidget() && prev->asType()->getElementTag() == "summary" ) + count++; + prev = prev->getPrevNode(); + } + return count; +} + +void UIHTMLSummary::invalidateListMarker() { + if ( !UIHTMLListStyle::isTextMarker( mListStyleType ) ) { + mListMarkerText.reset(); + } else { + String markerStr = + UIHTMLListStyle::getTextMarkerString( mListStyleType, countPrecedingSummarySiblings() ); + if ( !markerStr.empty() ) { + if ( !mListMarkerText ) + mListMarkerText = std::make_unique(); + mListMarkerText->setString( markerStr ); + mListMarkerText->setStyleConfig( mRichText.getFontStyleConfig() ); + } else { + mListMarkerText.reset(); + } + } + invalidateDraw(); +} + +void UIHTMLSummary::syncDefaultListStylePadding() { + if ( !mUsingDefaultListStylePadding ) + return; + + mApplyingDefaultListStylePadding = true; + UIRichText::applyProperty( StyleSheetProperty( + "padding-left", mListStyleType == CSSListStyleType::None ? "0dp" : "20dp" ) ); + mApplyingDefaultListStylePadding = false; +} + +Uint32 UIHTMLSummary::onMouseClick( const Vector2i& position, const Uint32& flags ) { + if ( toggleParentDetails() ) + return 1; + return UIRichText::onMouseClick( position, flags ); +} + +Uint32 UIHTMLSummary::onKeyDown( const KeyEvent& event ) { + if ( event.getKeyCode() == KEY_RETURN || event.getKeyCode() == KEY_KP_ENTER || + event.getKeyCode() == KEY_SPACE ) { + if ( toggleParentDetails() ) + return 1; + } + return UIRichText::onKeyDown( event ); +} + +void UIHTMLSummary::onFontChanged() { + UIRichText::onFontChanged(); + invalidateListMarker(); +} + +void UIHTMLSummary::onFontStyleChanged() { + UIRichText::onFontStyleChanged(); + invalidateListMarker(); +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uihtmllistitem.cpp b/src/eepp/ui/uihtmllistitem.cpp index f43f330a1..453abbd4c 100644 --- a/src/eepp/ui/uihtmllistitem.cpp +++ b/src/eepp/ui/uihtmllistitem.cpp @@ -1,6 +1,6 @@ -#include #include #include +#include #include namespace EE { namespace UI { @@ -41,36 +41,10 @@ void UIHTMLListItem::draw() { const FontStyleConfig& style = mRichText.getFontStyleConfig(); Float fontSize = style.CharacterSize; Float offset = 0.25f * fontSize; - Float lineHeight = - style.Font ? style.Font->getLineSpacing( (unsigned int)fontSize ) : fontSize; Float lineTop = mScreenPos.y + mPaddingPx.Top; - if ( mListStyleType == CSSListStyleType::Disc ) { - Float radius = fontSize * 0.22f; - Float markerX = std::floor( mScreenPos.x + mPaddingPx.Left - radius * 2.f - offset ); - Float markerY = std::floor( lineTop + ( lineHeight - radius * 2.f ) * 0.5f + radius ); - Primitives p; - p.setColor( style.FontColor ); - p.setFillMode( PrimitiveFillMode::DRAW_FILL ); - p.drawCircle( { markerX, markerY }, radius ); - } else if ( mListStyleType == CSSListStyleType::Circle ) { - Float radius = fontSize * 0.2f; - Float lineWidth = fontSize * 0.04f; - Float markerX = std::floor( mScreenPos.x + mPaddingPx.Left - radius * 2.f - offset ); - Float markerY = std::floor( lineTop + ( lineHeight - radius * 2.f ) * 0.5f + radius ); - Primitives p; - p.setColor( style.FontColor ); - p.setFillMode( PrimitiveFillMode::DRAW_LINE ); - p.setLineWidth( lineWidth ); - p.drawCircle( { markerX, markerY }, radius ); - } else if ( mListStyleType == CSSListStyleType::Square ) { - Float size = fontSize * 0.38f; - Float markerX = std::floor( mScreenPos.x + mPaddingPx.Left - size - fontSize * 0.5 ); - Float markerY = std::floor( lineTop + ( lineHeight - size ) * 0.5f ); - Primitives p; - p.setColor( style.FontColor ); - p.setFillMode( PrimitiveFillMode::DRAW_FILL ); - p.drawRectangle( Rectf( markerX, markerY, markerX + size, markerY + size ) ); + if ( UIHTMLListStyle::isPrimitiveMarker( mListStyleType ) ) { + UIHTMLListStyle::drawPrimitiveMarker( mListStyleType, mScreenPos, mPaddingPx, style ); } else if ( mListMarkerText && !mListMarkerText->getString().empty() ) { Float markerX = mScreenPos.x + mPaddingPx.Left - mListMarkerText->getTextWidth() - offset; @@ -118,9 +92,7 @@ std::vector UIHTMLListItem::getPropertiesImplemented() const { } void UIHTMLListItem::invalidateList() { - if ( mListStyleType == CSSListStyleType::None || mListStyleType == CSSListStyleType::Disc || - mListStyleType == CSSListStyleType::Circle || - mListStyleType == CSSListStyleType::Square ) { + if ( !UIHTMLListStyle::isTextMarker( mListStyleType ) ) { mListMarkerText.reset(); } else { String::View markerStr = getListMarkerString(); @@ -150,51 +122,11 @@ int UIHTMLListItem::countPrecedingLiSiblings() const { String::View UIHTMLListItem::getListMarkerString() const { static String sBuf; - switch ( mListStyleType ) { - case CSSListStyleType::None: - case CSSListStyleType::Disc: - case CSSListStyleType::Circle: - case CSSListStyleType::Square: - return {}; - case CSSListStyleType::Decimal: { - int idx = countPrecedingLiSiblings() + 1; - sBuf = String( String::toString( idx ) + ". " ); - return sBuf.view(); - } - case CSSListStyleType::LowerAlpha: { - int idx = countPrecedingLiSiblings(); - char c = 'a' + ( idx % 26 ); - sBuf = String( 1, (String::StringBaseType)c ) + ". "; - return sBuf.view(); - } - case CSSListStyleType::UpperAlpha: { - int idx = countPrecedingLiSiblings(); - char c = 'A' + ( idx % 26 ); - sBuf = String( 1, (String::StringBaseType)c ) + ". "; - return sBuf.view(); - } - case CSSListStyleType::LowerRoman: { - static const char* numerals[] = { "i", "ii", "iii", "iv", "v", "vi", - "vii", "viii", "ix", "x", "xi", "xii" }; - int idx = countPrecedingLiSiblings(); - if ( idx < 12 ) - sBuf = String( numerals[idx] ) + ". "; - else - sBuf = String( String::toString( idx + 1 ) + ". " ); - return sBuf.view(); - } - case CSSListStyleType::UpperRoman: { - static const char* numerals[] = { "I", "II", "III", "IV", "V", "VI", - "VII", "VIII", "IX", "X", "XI", "XII" }; - int idx = countPrecedingLiSiblings(); - if ( idx < 12 ) - sBuf = String( numerals[idx] ) + ". "; - else - sBuf = String( String::toString( idx + 1 ) + ". " ); - return sBuf.view(); - } - } - return {}; + if ( !UIHTMLListStyle::isTextMarker( mListStyleType ) ) + return {}; + + sBuf = UIHTMLListStyle::getTextMarkerString( mListStyleType, countPrecedingLiSiblings() ); + return sBuf.view(); } }} // namespace EE::UI diff --git a/src/eepp/ui/uihtmlliststyle.cpp b/src/eepp/ui/uihtmlliststyle.cpp new file mode 100644 index 000000000..071af31ba --- /dev/null +++ b/src/eepp/ui/uihtmlliststyle.cpp @@ -0,0 +1,115 @@ +#include +#include +#include + +namespace EE { namespace UI { + +bool UIHTMLListStyle::isPrimitiveMarker( CSSListStyleType type ) { + switch ( type ) { + case CSSListStyleType::Disc: + case CSSListStyleType::Circle: + case CSSListStyleType::Square: + case CSSListStyleType::DisclosureClosed: + case CSSListStyleType::DisclosureOpen: + return true; + default: + return false; + } +} + +bool UIHTMLListStyle::isTextMarker( CSSListStyleType type ) { + return type != CSSListStyleType::None && !isPrimitiveMarker( type ); +} + +String UIHTMLListStyle::getTextMarkerString( CSSListStyleType type, int index ) { + switch ( type ) { + case CSSListStyleType::Decimal: + return String( String::toString( index + 1 ) + ". " ); + case CSSListStyleType::LowerAlpha: { + char c = 'a' + ( index % 26 ); + return String( 1, (String::StringBaseType)c ) + ". "; + } + case CSSListStyleType::UpperAlpha: { + char c = 'A' + ( index % 26 ); + return String( 1, (String::StringBaseType)c ) + ". "; + } + case CSSListStyleType::LowerRoman: { + static const char* numerals[] = { "i", "ii", "iii", "iv", "v", "vi", + "vii", "viii", "ix", "x", "xi", "xii" }; + return index < 12 ? String( numerals[index] ) + ". " + : String( String::toString( index + 1 ) + ". " ); + } + case CSSListStyleType::UpperRoman: { + static const char* numerals[] = { "I", "II", "III", "IV", "V", "VI", + "VII", "VIII", "IX", "X", "XI", "XII" }; + return index < 12 ? String( numerals[index] ) + ". " + : String( String::toString( index + 1 ) + ". " ); + } + default: + return {}; + } +} + +void UIHTMLListStyle::drawPrimitiveMarker( CSSListStyleType type, const Vector2f& screenPos, + const Rectf& paddingPx, + const Graphics::FontStyleConfig& style ) { + Float fontSize = style.CharacterSize; + Float offset = 0.25f * fontSize; + Float lineHeight = style.Font ? style.Font->getLineSpacing( (unsigned int)fontSize ) : fontSize; + Float lineTop = screenPos.y + paddingPx.Top; + Graphics::Primitives p; + p.setColor( style.FontColor ); + + switch ( type ) { + case CSSListStyleType::Disc: { + Float radius = fontSize * 0.22f; + Float markerX = std::floor( screenPos.x + paddingPx.Left - radius * 2.f - offset ); + Float markerY = std::floor( lineTop + ( lineHeight - radius * 2.f ) * 0.5f + radius ); + p.setFillMode( Graphics::PrimitiveFillMode::DRAW_FILL ); + p.drawCircle( { markerX, markerY }, radius ); + break; + } + case CSSListStyleType::Circle: { + Float radius = fontSize * 0.2f; + Float lineWidth = fontSize * 0.04f; + Float markerX = std::floor( screenPos.x + paddingPx.Left - radius * 2.f - offset ); + Float markerY = std::floor( lineTop + ( lineHeight - radius * 2.f ) * 0.5f + radius ); + p.setFillMode( Graphics::PrimitiveFillMode::DRAW_LINE ); + p.setLineWidth( lineWidth ); + p.drawCircle( { markerX, markerY }, radius ); + break; + } + case CSSListStyleType::Square: { + Float size = fontSize * 0.38f; + Float markerX = std::floor( screenPos.x + paddingPx.Left - size - fontSize * 0.5 ); + Float markerY = std::floor( lineTop + ( lineHeight - size ) * 0.5f ); + p.setFillMode( Graphics::PrimitiveFillMode::DRAW_FILL ); + p.drawRectangle( Rectf( markerX, markerY, markerX + size, markerY + size ) ); + break; + } + case CSSListStyleType::DisclosureClosed: { + Float size = fontSize * 0.48f; + Float cx = std::floor( screenPos.x + paddingPx.Left - fontSize * 0.8f ); + Float cy = std::floor( lineTop + lineHeight * 0.5f ); + p.setFillMode( Graphics::PrimitiveFillMode::DRAW_FILL ); + p.drawTriangle( Triangle2f( { cx - size * 0.35f, cy - size * 0.5f }, + { cx - size * 0.35f, cy + size * 0.5f }, + { cx + size * 0.35f, cy } ) ); + break; + } + case CSSListStyleType::DisclosureOpen: { + Float size = fontSize * 0.52f; + Float cx = std::floor( screenPos.x + paddingPx.Left - fontSize * 0.8f ); + Float cy = std::floor( lineTop + lineHeight * 0.5f ); + p.setFillMode( Graphics::PrimitiveFillMode::DRAW_FILL ); + p.drawTriangle( Triangle2f( { cx - size * 0.5f, cy - size * 0.25f }, + { cx + size * 0.5f, cy - size * 0.25f }, + { cx, cy + size * 0.45f } ) ); + break; + } + default: + break; + } +} + +}} // namespace EE::UI diff --git a/src/eepp/ui/uiwidgetcreator.cpp b/src/eepp/ui/uiwidgetcreator.cpp index 58397b810..265a8160c 100644 --- a/src/eepp/ui/uiwidgetcreator.cpp +++ b/src/eepp/ui/uiwidgetcreator.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -200,6 +201,8 @@ void UIWidgetCreator::createBaseWidgetList() { return w; }; registeredWidget["li"] = UIHTMLListItem::New; + registeredWidget["details"] = UIHTMLDetails::New; + registeredWidget["summary"] = UIHTMLSummary::New; registeredWidget["pre"] = UIRichText::NewPre; registeredWidget["picture"] = [] { return UITextSpan::NewWithTag( "picture" ); }; registeredWidget["img"] = UIHTMLImage::New; diff --git a/src/tests/unit_tests/uihtml_tests.cpp b/src/tests/unit_tests/uihtml_tests.cpp index 871636caa..757ea6bc6 100644 --- a/src/tests/unit_tests/uihtml_tests.cpp +++ b/src/tests/unit_tests/uihtml_tests.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -12,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -580,6 +582,329 @@ UTEST( UIHTML, DataProperties ) { Engine::destroySingleton(); } +UTEST( UIHTMLDetails, closedByDefault ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
Label

Content

+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* details = sceneNode->getRoot()->find( "d" )->asType(); + auto* summary = sceneNode->getRoot()->find( "s" )->asType(); + auto* content = sceneNode->getRoot()->find( "p" )->asType(); + ASSERT_TRUE( details != nullptr ); + ASSERT_TRUE( summary != nullptr ); + ASSERT_TRUE( content != nullptr ); + EXPECT_FALSE( details->isOpen() ); + EXPECT_TRUE( summary->isVisible() ); + EXPECT_FALSE( content->isVisible() ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, summaryListStyleType ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
+ Default + Open marker + Decimal marker +
+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + const auto* propDef = StyleSheetSpecification::instance()->getProperty( "list-style-type" ); + ASSERT_TRUE( propDef != nullptr ); + + auto* s1 = sceneNode->getRoot()->find( "s1" )->asType(); + auto* s2 = sceneNode->getRoot()->find( "s2" )->asType(); + auto* s3 = sceneNode->getRoot()->find( "s3" )->asType(); + ASSERT_TRUE( s1 != nullptr ); + ASSERT_TRUE( s2 != nullptr ); + ASSERT_TRUE( s3 != nullptr ); + + EXPECT_TRUE( s1->getPropertyString( propDef ) == "disclosure-closed" ); + EXPECT_TRUE( s2->getPropertyString( propDef ) == "disclosure-open" ); + EXPECT_TRUE( s3->getPropertyString( propDef ) == "decimal" ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, summaryListStyleNoneClearsDefaultPadding ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
+ Default marker + No marker + + Explicit padding + +
+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* defaultSummary = sceneNode->getRoot()->find( "default" )->asType(); + auto* noneSummary = sceneNode->getRoot()->find( "none" )->asType(); + auto* explicitSummary = sceneNode->getRoot()->find( "explicit" )->asType(); + ASSERT_TRUE( defaultSummary != nullptr ); + ASSERT_TRUE( noneSummary != nullptr ); + ASSERT_TRUE( explicitSummary != nullptr ); + + EXPECT_GT( defaultSummary->getPixelsPadding().Left, 0.f ); + EXPECT_NEAR( noneSummary->getPixelsPadding().Left, 0.f, 0.5f ); + EXPECT_NEAR( explicitSummary->getPixelsPadding().Left, 7.f, 0.5f ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, inlineBlockSummaryListStyleNoneSize ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + std::string html; + FileSystem::fileGet( "assets/html/lobsters_item.html", html ); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( html ) ); + sceneNode->updateDirtyLayouts(); + + auto* details = sceneNode->getRoot()->querySelector( ".caches" )->asType(); + auto* summary = + sceneNode->getRoot()->querySelector( ".caches summary" )->asType(); + auto* author = sceneNode->getRoot()->querySelector( ".u-author" )->asType(); + auto* time = sceneNode->getRoot()->querySelector( "time" )->asType(); + ASSERT_TRUE( details != nullptr ); + ASSERT_TRUE( summary != nullptr ); + ASSERT_TRUE( author != nullptr ); + ASSERT_TRUE( time != nullptr ); + + EXPECT_EQ( details->getDisplay(), CSSDisplay::InlineBlock ); + EXPECT_EQ( summary->getListStyleType(), CSSListStyleType::None ); + EXPECT_NEAR( summary->getPixelsPadding().Left, 0.f, 0.5f ); + EXPECT_LE( details->getPixelsSize().getHeight(), + eemax( author->getPixelsSize().getHeight(), time->getPixelsSize().getHeight() ) + + 1.f ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, openAttribute ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
Label

Content

+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* details = sceneNode->getRoot()->find( "d" )->asType(); + auto* content = sceneNode->getRoot()->find( "p" )->asType(); + ASSERT_TRUE( details != nullptr ); + ASSERT_TRUE( content != nullptr ); + EXPECT_TRUE( details->isOpen() ); + EXPECT_TRUE( content->isVisible() ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, openAttributeExplicitFalse ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
Label

Content

+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* details = sceneNode->getRoot()->find( "d" )->asType(); + auto* content = sceneNode->getRoot()->find( "p" )->asType(); + ASSERT_TRUE( details != nullptr ); + ASSERT_TRUE( content != nullptr ); + EXPECT_FALSE( details->isOpen() ); + EXPECT_FALSE( content->isVisible() ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, toggleViaMouse ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
Label

Content

+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* details = sceneNode->getRoot()->find( "d" )->asType(); + auto* summary = sceneNode->getRoot()->find( "s" )->asType(); + auto* content = sceneNode->getRoot()->find( "p" )->asType(); + ASSERT_TRUE( details != nullptr ); + ASSERT_TRUE( summary != nullptr ); + ASSERT_TRUE( content != nullptr ); + + summary->onMouseClick( summary->getPixelsPosition().asInt(), EE_BUTTON_LMASK ); + EXPECT_TRUE( details->isOpen() ); + EXPECT_TRUE( content->isVisible() ); + summary->onMouseClick( summary->getPixelsPosition().asInt(), EE_BUTTON_LMASK ); + EXPECT_FALSE( details->isOpen() ); + EXPECT_FALSE( content->isVisible() ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, toggleViaKeyboard ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
Label

Content

+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* details = sceneNode->getRoot()->find( "d" )->asType(); + auto* summary = sceneNode->getRoot()->find( "s" )->asType(); + ASSERT_TRUE( details != nullptr ); + ASSERT_TRUE( summary != nullptr ); + + KeyEvent enter( summary, Event::KeyDown, KEY_RETURN, SCANCODE_RETURN, 0, 0 ); + KeyEvent space( summary, Event::KeyDown, KEY_SPACE, SCANCODE_SPACE, 0, 0 ); + EXPECT_EQ( summary->onKeyDown( enter ), 1u ); + EXPECT_TRUE( details->isOpen() ); + EXPECT_EQ( summary->onKeyDown( space ), 1u ); + EXPECT_FALSE( details->isOpen() ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, toggleEvent ) { + init_ui_test(); + auto* details = UIHTMLDetails::New(); + details->setParent( SceneManager::instance()->getUISceneNode()->getRoot() ); + int toggleCount = 0; + details->on( Event::OnToggle, [&toggleCount]( const Event* ) { toggleCount++; } ); + + details->setOpen( true ); + details->setOpen( true ); + details->setOpen( false ); + EXPECT_EQ( toggleCount, 2 ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, autoSummary ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +

Content

+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* details = sceneNode->getRoot()->find( "d" )->asType(); + auto* content = sceneNode->getRoot()->find( "p" )->asType(); + ASSERT_TRUE( details != nullptr ); + ASSERT_TRUE( content != nullptr ); + auto* summary = details->findSummaryChild(); + ASSERT_TRUE( summary != nullptr ); + EXPECT_TRUE( summary->isVisible() ); + EXPECT_FALSE( content->isVisible() ); + EXPECT_TRUE( summary->toggleParentDetails() ); + EXPECT_TRUE( details->isOpen() ); + EXPECT_TRUE( content->isVisible() ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, multipleSummaries ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
+ One + Two +

Content

+
+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* details = sceneNode->getRoot()->find( "d" )->asType(); + auto* s1 = sceneNode->getRoot()->find( "s1" )->asType(); + auto* s2 = sceneNode->getRoot()->find( "s2" )->asType(); + ASSERT_TRUE( details != nullptr ); + ASSERT_TRUE( s1 != nullptr ); + ASSERT_TRUE( s2 != nullptr ); + EXPECT_TRUE( s1->isVisible() ); + EXPECT_FALSE( s2->isVisible() ); + EXPECT_TRUE( s1->toggleParentDetails() ); + EXPECT_TRUE( details->isOpen() ); + EXPECT_TRUE( s2->isVisible() ); + EXPECT_FALSE( s2->toggleParentDetails() ); + EXPECT_TRUE( details->isOpen() ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, nested ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
+ Outer +
+ Inner +

Inner content

+
+
+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* outer = sceneNode->getRoot()->find( "outer" )->asType(); + auto* inner = sceneNode->getRoot()->find( "inner" )->asType(); + auto* innerSummary = sceneNode->getRoot()->find( "inner_s" )->asType(); + ASSERT_TRUE( outer != nullptr ); + ASSERT_TRUE( inner != nullptr ); + ASSERT_TRUE( innerSummary != nullptr ); + EXPECT_TRUE( outer->isOpen() ); + EXPECT_FALSE( inner->isOpen() ); + EXPECT_TRUE( innerSummary->toggleParentDetails() ); + EXPECT_TRUE( outer->isOpen() ); + EXPECT_TRUE( inner->isOpen() ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, hiddenChildPreserved ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( HTMLFormatter::HTMLtoXML( R"html( +
Label
+ )html" ) ); + sceneNode->updateDirtyLayouts(); + + auto* details = sceneNode->getRoot()->find( "d" )->asType(); + auto* content = sceneNode->getRoot()->find( "p" )->asType(); + ASSERT_TRUE( details != nullptr ); + ASSERT_TRUE( content != nullptr ); + EXPECT_FALSE( content->isVisible() ); + details->setOpen( false ); + details->setOpen( true ); + EXPECT_FALSE( content->isVisible() ); + + Engine::destroySingleton(); +} + +UTEST( UIHTMLDetails, dynamicChildAddedWhileClosed ) { + init_ui_test(); + auto* details = UIHTMLDetails::New(); + details->setParent( SceneManager::instance()->getUISceneNode()->getRoot() ); + auto* summary = UIHTMLSummary::New(); + summary->setParent( details ); + details->setOpen( false ); + auto* content = UIRichText::NewParagraph(); + content->setParent( details ); + details->updateLayout(); + + EXPECT_TRUE( summary->isVisible() ); + EXPECT_FALSE( content->isVisible() ); + + Engine::destroySingleton(); +} + UTEST( UIHTMLTextArea, rowsColsAttribute ) { init_ui_test(); auto* scene = SceneManager::instance()->getUISceneNode(); @@ -883,6 +1208,34 @@ UTEST( UILayout, listStyleTypeDisc ) { Engine::destroySingleton(); } +UTEST( UILayout, listStyleTypeDisclosure ) { + init_ui_test(); + auto* sceneNode = SceneManager::instance()->getUISceneNode(); + sceneNode->loadLayoutFromString( R"html( + +
    +
  • Closed
  • +
  • Open
  • +
+ + )html" ); + + sceneNode->updateDirtyLayouts(); + + const auto* propDef = StyleSheetSpecification::instance()->getProperty( "list-style-type" ); + ASSERT_TRUE( propDef != nullptr ); + + auto* li1 = sceneNode->getRoot()->find( "li1" )->asType(); + auto* li2 = sceneNode->getRoot()->find( "li2" )->asType(); + ASSERT_TRUE( li1 != nullptr ); + ASSERT_TRUE( li2 != nullptr ); + + EXPECT_TRUE( li1->getPropertyString( propDef ) == "disclosure-closed" ); + EXPECT_TRUE( li2->getPropertyString( propDef ) == "disclosure-open" ); + + Engine::destroySingleton(); +} + UTEST( UILayout, listStyleShorthand ) { init_ui_test(); auto* sceneNode = SceneManager::instance()->getUISceneNode();