Files
sbox-public/engine/Sandbox.Engine/Systems/UI/Parser/StyleParser.Selector.cs
Garry Newman d27c236689 UI additions and optimizations (#4968)
ADDED
css min(), max() and clamp() for lengths
css-wide keywords inherit, initial, unset, revert (incl. shorthands)
added currentColor keyword to css
colors can parse oklch(), lab() and hwb()
added css text-align: justify
css white-space: pre-wrap and break-spaces
added css word-break: break-word
parses css image-rendering: crisp-edges (point sampling)
added css inset shorthand
now parses 1–4 values in the css border-width shorthand
added css transition-duration, -delay, -property and -timing-function longhands
css :has() now supports descendant selectors
added css opacity percentages
added css overflow: auto (maps to scroll)
added css gap: normal
added css filter: none
css transform: scale() now takes comma-separated args
css animations now accept ms units
added more css justify-content, align-* and text-align keywords (start, end, …)
parses css dvh/svh/lvh/dvw viewport units (treated as vh/vw)
parses css object-fit: scale-down (treated as contain)
added css margin-block / margin-inline (+ the logical margin sides)
added css padding-block / padding-inline (+ the logical padding sides)
added css inset-block / inset-inline (+ the logical inset sides)
added the css flex-flow shorthand
added the css font shorthand
added css font-size keywords (xx-small … xxx-large)
css letter-spacing and word-spacing now accept normal
added css font-smooth: none
css aspect-ratio now accepts the auto form
css font-family now maps generic families (serif, sans-serif, monospace)
added css flex-flow
added css font

FIXED
fixed css !important dropping the whole declaration
css line-height without units is now a multiplier
fixed css calc() division and full-expression parsing
fixed the css flex shorthand for single-number and three-value forms
fixed the css transition shorthand when the property is omitted
css font-family now uses the first family in a comma stack
colors now parse fully in the css background shorthand
an unknown css @-rule no longer drops the whole stylesheet
css variables no longer match on partial tokens
injected css variables are kept across stylesheet hotloads
fixed css selector specificity overflowing when comparing rules
css background: none now resets the background (image and colour)
css filter: none now overrides a filter set by a base class
css transform: none now overrides a transform set by a base class

IMPROVED
css rule matching is ~3–5× faster on large stylesheets (rules indexed by class)
~2× fewer allocations in css transitions (snapshot styles once, not per property)
finished css animations no longer re-layout every frame
less GC in css sibling/child selector queries (no list copies or wrappers)
less GC diffing active css rules (no LINQ or hashsets)
faster, lower-GC Yoga layout wrapper
css stylesheet hotload now watches newly imported files
2026-06-02 19:19:23 +01:00

408 lines
8.5 KiB
C#

//
//
// Selectors = [selector], [selector], [selector]
// Selector = [rule] [rule] [rule] [rule]
// rule = ELEMENT.Class.Class.Class:hover:and:stuff
//
namespace Sandbox.UI;
internal static partial class StyleParser
{
/// <summary>
/// Here we divide the selectors into groups
/// .fucker, .cocks, .hairy
/// </summary>
public static List<StyleSelector> Selector( string rule_string, StyleBlock parent = null )
{
var list = new List<StyleSelector>();
var p = new Parse( rule_string );
while ( !p.IsEnd )
{
p = p.SkipWhitespaceAndNewlines();
if ( p.IsEnd )
break;
if ( p.Current == ',' )
throw new System.Exception( $"Invalid Selector: {rule_string}" );
var group = p.ReadUntilOrEnd( "," );
group = group.Trim();
var ss = ParseSelector( group, parent );
if ( ss == null )
return null;
list.Add( ss );
if ( !p.IsEnd && p.Current == ',' )
p.Pointer++;
}
return list;
}
public static StyleSelector ParseSelector( string rule_string, StyleBlock parent = null )
{
var p = new Parse( rule_string );
StyleSelector lastRule = null;
while ( !p.IsEnd )
{
p = p.SkipWhitespaceAndNewlines();
if ( p.IsEnd )
break;
bool immediateParent = false;
bool adjacentSibling = false;
bool generalSibling = false;
if ( p.Is( '>' ) )
{
p.Pointer++;
p = p.SkipWhitespaceAndNewlines();
immediateParent = true;
}
else if ( p.Is( '+' ) )
{
p.Pointer++;
p = p.SkipWhitespaceAndNewlines();
adjacentSibling = true;
}
else if ( p.Is( '~' ) )
{
p.Pointer++;
p = p.SkipWhitespaceAndNewlines();
generalSibling = true;
}
var selector = p.ReadUntilWhitespaceOrNewlineOrEndAndObeyBrackets();
var rule = ParseSingleSelector( selector, parent );
if ( rule == null )
return null;
rule.Parent = lastRule;
rule.ImmediateParent = immediateParent;
rule.AdjacentSibling = adjacentSibling;
rule.GeneralSibling = generalSibling;
lastRule = rule;
}
if ( lastRule != null )
{
lastRule.AsString = rule_string.Trim();
if ( parent != null )
{
var parentRules = string.Join( ", ", parent.Selectors.Select( x => x.AsString ) );
if ( lastRule.AsString.StartsWith( '&' ) )
{
lastRule.AsString = parentRules + lastRule.AsString.Substring( 1 );
}
else
{
lastRule.AsString = parentRules + " " + lastRule.AsString;
}
}
}
return lastRule;
}
/// <summary>
/// Parse a single rule, which as "panel.closed.error:hover"
/// </summary>
/// <returns></returns>
public static StyleSelector ParseSingleSelector( string rule_string, StyleBlock parent )
{
var seperators = ".:";
var rule = new StyleSelector();
rule.AsString = rule_string.Trim();
var p = new Parse( rule_string );
p = p.SkipWhitespaceAndNewlines();
List<string> ruleClasses = null;
//
// If our selector starts with & we need to match any of the parent block's selectors
//
if ( p.Current == '&' )
{
p.Pointer++;
if ( parent == null )
throw new System.Exception( $"Starts with & but has no parent block \"{rule_string}\"" );
rule.AnyOf = parent.Selectors;
}
else if ( p.Current == '>' )
{
p.Pointer++;
if ( parent == null )
throw new System.Exception( $"Starts with > but has no parent block \"{rule_string}\"" );
rule.DecendantOf = parent.Selectors;
rule.ImmediateParent = true;
}
else if ( parent != null )
{
//
// If we have a parent block, our parent needs to conform to its rules
//
rule.DecendantOf = parent.Selectors;
}
while ( !p.IsEnd )
{
//
// Class
//
if ( p.Current == '.' )
{
p.Pointer++;
if ( p.IsEnd || p.IsOneOf( seperators ) )
throw new System.Exception( $"Invalid Rule \"{rule_string}\"" );
var classname = p.ReadUntilOrEnd( ".:#" ).ToLowerInvariant();
ruleClasses ??= new();
ruleClasses.Add( classname );
}
else if ( p.Current == '#' )
{
p.Pointer++;
if ( p.IsEnd || p.IsOneOf( seperators ) )
throw new System.Exception( $"Invalid Rule \"{rule_string}\"" );
var id = p.ReadUntilOrEnd( ".:#" ).ToLower();
rule.Id = id;
}
else if ( p.Current == ':' )
{
// there might be 2, skip them all
while ( p.Current == ':' )
p.Pointer++;
if ( p.IsEnd || p.IsOneOf( seperators ) )
throw new System.Exception( $"Invalid Rule \"{rule_string}\"" );
ReadPseudoClass( rule, ref p );
}
else if ( p.Current == '*' )
{
p.Pointer++;
rule.UniversalSelector = true;
if ( !p.IsEnd && !p.IsOneOf( seperators ) )
throw new System.Exception( $"Invalid Rule \"{rule_string}\"" );
}
else
{
rule.Element = p.ReadUntilOrEnd( ".:#" ).ToLower();
}
}
if ( ruleClasses != null )
{
rule.SetClasses( ruleClasses.ToArray() );
}
return rule;
}
private static void ReadPseudoClass( StyleSelector rule, ref Parse p )
{
if ( p.Is( "has(", 0, true ) )
{
p.Pointer += 3;
var inner = p.ReadInnerBrackets();
rule.Has = ParseHasSelectors( inner );
return;
}
if ( p.Is( "not(", 0, true ) )
{
p.Pointer += 3;
var inner = p.ReadInnerBrackets();
if ( string.IsNullOrEmpty( inner ) ) return;
rule.Not = ParseSelector( inner );
return;
}
if ( p.Is( "nth-child(", 0, true ) )
{
p.Pointer += "nth-child".Length;
var inner = p.ReadInnerBrackets();
if ( string.IsNullOrEmpty( inner ) ) return;
ParseNthChild( rule, inner.Trim() );
return;
}
var flagname = p.ReadUntilOrEnd( ".:" ).ToLowerInvariant();
switch ( flagname )
{
case "hover":
rule.Flags |= PseudoClass.Hover;
break;
case "active":
rule.Flags |= PseudoClass.Active;
break;
case "focus":
rule.Flags |= PseudoClass.Focus;
break;
case "intro":
rule.Flags |= PseudoClass.Intro;
break;
case "outro":
rule.Flags |= PseudoClass.Outro;
break;
case "empty":
rule.Flags |= PseudoClass.Empty;
break;
case "first-child":
rule.Flags |= PseudoClass.FirstChild;
break;
case "last-child":
rule.Flags |= PseudoClass.LastChild;
break;
case "only-child":
rule.Flags |= PseudoClass.OnlyChild;
break;
case "before":
rule.Flags |= PseudoClass.Before;
break;
case "after":
rule.Flags |= PseudoClass.After;
break;
default:
throw new System.Exception( $"Unsupported Pseudo Class \"{flagname}\"" );
}
}
private static void ParseNthChild( StyleSelector rule, string inner )
{
if ( int.TryParse( inner, out int intValue ) )
{
rule.NthChild = ( p ) => (p.SiblingIndex + 1) == intValue;
return;
}
if ( string.Equals( inner, "odd", StringComparison.OrdinalIgnoreCase ) )
{
rule.NthChild = ( p ) => p.SiblingIndex % 2 == 0;
return;
}
if ( string.Equals( inner, "even", StringComparison.OrdinalIgnoreCase ) )
{
rule.NthChild = ( p ) => p.SiblingIndex % 2 == 1;
return;
}
throw new System.Exception( $"unsupported NthChild \"{inner}\"" );
}
private static StyleSelector[] ParseHasSelectors( string inner )
{
if ( string.IsNullOrWhiteSpace( inner ) )
return null;
var selectors = new List<StyleSelector>();
foreach ( var part in inner.Split( ',' ) )
{
var trimmed = part.Trim();
if ( string.IsNullOrEmpty( trimmed ) ) continue;
var selector = ParseHasSelector( trimmed );
if ( selector == null ) continue;
selectors.Add( selector );
}
if ( selectors.Count == 0 )
return null;
return selectors.ToArray();
}
private static StyleSelector ParseHasSelector( string selectorString )
{
if ( string.IsNullOrWhiteSpace( selectorString ) )
return null;
var p = new Parse( selectorString );
p = p.SkipWhitespaceAndNewlines();
// Handle different combinator types at the start
bool isDirectChild = false;
bool isAdjacent = false;
bool isGeneral = false;
if ( !p.IsEnd && p.Current == '>' )
{
isDirectChild = true;
p.Pointer++;
p = p.SkipWhitespaceAndNewlines();
}
else if ( !p.IsEnd && p.Current == '+' )
{
isAdjacent = true;
p.Pointer++;
p = p.SkipWhitespaceAndNewlines();
}
else if ( !p.IsEnd && p.Current == '~' )
{
isGeneral = true;
p.Pointer++;
p = p.SkipWhitespaceAndNewlines();
}
if ( p.IsEnd ) return null;
var remaining = p.ReadRemaining();
if ( string.IsNullOrWhiteSpace( remaining ) )
return null;
var selector = ParseSelector( remaining );
if ( selector != null )
{
if ( isDirectChild ) selector.ImmediateParent = true;
else if ( isAdjacent ) selector.AdjacentSibling = true;
else if ( isGeneral ) selector.GeneralSibling = true;
}
return selector;
}
}