Files
sbox-public/engine/Sandbox.Engine/Systems/UI/Engine/TextBlock.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

715 lines
20 KiB
C#

using Sandbox.Html;
using Sandbox.Rendering;
using SkiaSharp;
using System.Buffers;
using Topten.RichTextKit;
namespace Sandbox.UI;
internal sealed class TextBlock : IDisposable
{
[ConVar( ConVarFlags.Protected, Help = "Enable rendering text to textures" )]
public static bool ui_rendertext { get; set; } = true;
public Action OnTextureChanged { get; set; }
public string Text { get; internal set; }
public bool ShouldDrawSelection = false;
public int SelectionStart { get; internal set; } = 0;
public int SelectionEnd { get; internal set; } = 0;
public Color SelectionColor { get; set; } = Color.Cyan.WithAlpha( 0.39f );
public bool IsTruncated { get; internal set; }
public bool NoWrap { get; internal set; }
public bool IsHtml { get; internal set; }
public Func<INode, Styles> LookupStyles { get; set; }
public Vector2 BlockSize;
internal Texture Texture;
// we keep the last texture around incase we can re-use it
Texture LastTexture;
internal void SetText( string text )
{
Text = text;
IsHtml = false;
}
Sandbox.Html.Node htmlNode;
internal void SetHtml( string text )
{
if ( IsHtml && Text == text ) return;
Text = text;
IsHtml = true;
try
{
htmlNode = default;
htmlNode = Sandbox.Html.Node.Parse( Text );
}
catch { }
}
public void Dirty()
{
FontHash = default;
}
Topten.RichTextKit.TextBlock Block;
Topten.RichTextKit.Style Style;
Topten.RichTextKit.TextGradient Gradient;
int FontHash;
//int ParentHash;
float FontSize;
int? FontWeight;
TextAlign TextAlign;
TextOverflow TextOverflow;
FilterMode TextFilter;
TextDecoration TextDecoration;
FontStyle FontStyle;
FontVariantNumeric? FontVariantNumeric;
WordBreak WordBreak;
TextTransform? TextTransform;
Length? LetterSpacing;
Length? WordSpacing;
Length? LineHeight;
Align AlignItems;
WhiteSpace? WhiteSpace;
GradientInfo GradientInfo;
FontSmooth Smooth;
Margin EffectMargin;
Dictionary<int, Vector2> SizeCache = new Dictionary<int, Vector2>();
public Vector2 Measure( float width, float height )
{
if ( !float.IsNaN( width ) ) width = width.CeilToInt();
if ( !float.IsNaN( height ) ) height = height.CeilToInt();
var hash = (int)width;
if ( SizeCache.TryGetValue( hash, out var size ) )
return size;
Block.MaxWidth = float.IsNaN( width ) ? null : (width + 1);
if ( TextOverflow != TextOverflow.None )
{
Block.MaxHeight = float.IsNaN( height ) ? null : (height + 1);
}
var s = new Vector2( Block.MeasuredWidth.CeilToInt(), Block.MeasuredHeight.CeilToInt() );
SizeCache[hash] = s;
return s;
}
void WaitTextureReady()
{
if ( TextureRebuild == null ) return;
using var perfScope = Performance.Scope( "TextBlock.WaitRebuild" );
TextureRebuild.Wait();
TextureRebuild = null;
}
/// <summary>
/// Build a text descriptor into the target RenderLayer.
/// </summary>
internal void BuildDescriptors( RenderLayer target, BlendMode blendMode, Styles currentStyle, Rect textrect, float opacity )
{
WaitTextureReady();
if ( Texture is null ) return;
if ( BlockSize == 0 ) return;
if ( currentStyle.TextAlign == TextAlign.Center )
{
textrect.Left += (textrect.Width - BlockSize.x) * 0.5f;
}
else if ( currentStyle.TextAlign == TextAlign.Right )
{
textrect.Left = textrect.Right - BlockSize.x;
}
if ( currentStyle.AlignItems == Align.Center )
{
textrect.Top += (textrect.Height - BlockSize.y) * 0.5f;
}
else if ( currentStyle.AlignItems == Align.FlexEnd )
{
textrect.Top = textrect.Bottom - BlockSize.y;
}
textrect.Size = Texture.Size;
textrect.Position -= EffectMargin.Position;
var color = Color.White;
color.a *= opacity;
if ( color.a <= 0 ) return;
var rect = textrect.Floor();
var desc = new BoxDrawDescriptor( rect, new Color( 0, 0, 0, 0 ) )
{
BackgroundImage = Texture,
BackgroundRect = new Vector4( 0, 0, rect.Width, rect.Height ),
BackgroundTint = color,
OverrideBlendMode = blendMode == BlendMode.Normal ? BlendMode.PremultipliedAlpha : blendMode,
PremultiplyAlpha = true,
FilterMode = TextFilter,
};
target.AddBox( desc );
}
public Rect CaretRect( int caretPosition )
{
var codepoint = CaretToCodePointIndex( caretPosition );
// Skias caret includes newlines however for rendering, we don't want this
// It also appears AltPosition is absolutely fucked and changes nothing
var cp = new CaretPosition { AltPosition = false, CodePointIndex = codepoint };
var pos = Block.GetCaretInfo( cp );
float xPosition = pos.CaretRectangle.Left;
float yPosition = pos.CaretRectangle.Top;
if ( codepoint > 0 && codepoint == Block.Length && Text.Length > 0 && Text[^1] == '\n' )
{
xPosition = 0;
yPosition += Block.Lines[pos.LineIndex].Height;
}
return new Rect( xPosition, yPosition, pos.CaretRectangle.Width, pos.CaretRectangle.Height );
}
public int GetLetterAt( Vector2 pos )
{
if ( Block == null ) return -1;
var result = Block.HitTest( pos.x, pos.y );
return Block.LookupCaretIndex( result.ClosestCodePointIndex );
}
public HtmlSpan GetSpanAt( Vector2 pos )
{
if ( Block == null ) return default;
if ( HtmlSpans is null ) return default;
var result = Block.HitTest( pos.x, pos.y );
return HtmlSpans.Where( x => x.from <= result.OverCodePointIndex && x.to > result.OverCodePointIndex ).FirstOrDefault();
}
public bool UpdateStyles( Styles style )
{
var fontFamily = style.FontFamily ?? "Arial";
var fontColor = style.FontColor ?? Color.Black;
var fontSize = style.FontSize ?? Length.Pixels( 13 ).Value;
FontSize = fontSize.GetPixels( 100 ); // this should probably be screen height for font length?
FontSize = MathF.Round( FontSize * 32.0f ) / 32.0f; // round the font size so we're not redrawing for no reason on font size lerp
FontWeight = style.FontWeight;
TextAlign = style.TextAlign.Value;
TextDecoration = style.TextDecorationLine.Value;
FontStyle = style.FontStyle.Value;
FontVariantNumeric = style.FontVariantNumeric;
AlignItems = style.AlignItems.Value;
LetterSpacing = style.LetterSpacing;
WordSpacing = style.WordSpacing;
LineHeight = style.LineHeight;
WhiteSpace = style.WhiteSpace;
TextTransform = style.TextTransform;
GradientInfo = style.TextGradient;
TextOverflow = style.TextOverflow.Value;
TextFilter = style.TextFilter.Value;
WordBreak = style.WordBreak.Value;
Smooth = style.FontSmooth.Value;
var hash = HashCode.Combine( FontSize, fontColor, fontFamily, FontWeight, TextAlign, WhiteSpace, TextDecoration, FontStyle );
hash = HashCode.Combine( hash, LetterSpacing, TextTransform, Text, SelectionStart, SelectionEnd, ShouldDrawSelection, style.TextShadow );
hash = HashCode.Combine( hash, style.TextStrokeWidth, style.TextStrokeColor, style.TextDecorationColor, style.TextDecorationThickness, style.TextDecorationSkipInk, style.TextDecorationStyle );
hash = HashCode.Combine( hash, style.TextUnderlineOffset, style.TextOverlineOffset, style.TextLineThroughOffset, style.TextGradient, style.TextOverflow, style.WordBreak, style.LineHeight );
hash = HashCode.Combine( hash, style.WordSpacing );
hash = HashCode.Combine( hash, Smooth, FontVariantNumeric );
if ( FontHash == hash && Block != null )
return false;
//
// Create a hash of things on the font that could change
//
FontHash = hash;
Style ??= new Style();
Style.FontFamily = fontFamily;
Style.FontSize = FontSize;
Style.FontWeight = FontWeight ?? 400;
Style.FontItalic = FontStyle != FontStyle.None;
Style.FontVariantNumeric = FontVariantNumeric ?? UI.FontVariantNumeric.Normal;
Style.TextColor = fontColor.ToSk();
Style.Underline = UnderlineStyle.None;
Style.StrokeInkSkip = style.TextDecorationSkipInk == TextSkipInk.All;
Style.UnderlineOffset = style.TextUnderlineOffset.Value.GetPixels( 100 );
Style.OverlineOffset = style.TextOverlineOffset.Value.GetPixels( 100 );
Style.StrikeThroughOffset = style.TextLineThroughOffset.Value.GetPixels( 100 );
switch ( style.TextDecorationStyle )
{
case TextDecorationStyle.Solid:
Style.UnderlineStrokeType = UnderlineType.Solid;
break;
case TextDecorationStyle.Double:
Style.UnderlineStrokeType = UnderlineType.Double;
break;
case TextDecorationStyle.Dotted:
Style.UnderlineStrokeType = UnderlineType.Dotted;
break;
case TextDecorationStyle.Dashed:
Style.UnderlineStrokeType = UnderlineType.Dashed;
break;
case TextDecorationStyle.Wavy:
Style.UnderlineStrokeType = UnderlineType.Wavy;
break;
default:
Style.UnderlineStrokeType = UnderlineType.Solid;
break;
}
Style.UnderlineColor = (style.TextDecorationColor ?? fontColor).ToSk();
Style.StrokeThickness = style.TextDecorationThickness?.GetPixels( 100.0f );
Style.Underline |= (TextDecoration & UI.TextDecoration.Underline) != 0 ? UnderlineStyle.Gapped : UnderlineStyle.None;
Style.Underline |= (TextDecoration & UI.TextDecoration.Overline) != 0 ? UnderlineStyle.Overline : UnderlineStyle.None;
Style.StrikeThrough = (TextDecoration & UI.TextDecoration.LineThrough) != 0 ? StrikeThroughStyle.Solid : StrikeThroughStyle.None;
Style.LetterSpacing = LetterSpacing.Value.GetPixels( 1000.0f );
Style.WordSpacing = WordSpacing.Value.GetPixels( 1000.0f );
Style.LineHeight = GetLineHeightMultiplier();
Style.ClearEffects();
Gradient = null;
EffectMargin = default;
if ( !style.TextGradient.ColorOffsets.IsDefaultOrEmpty )
{
var colors = style.TextGradient.ColorOffsets.Select( x => SkiaCompat.ToSk( x.color ) ).ToArray();
var stops = style.TextGradient.ColorOffsets.Select( x => x.offset.Value ).ToArray();
if ( style.TextGradient.GradientType == GradientInfo.GradientTypes.Linear )
{
Gradient = TextGradient.Linear( colors, stops, style.TextGradient.Angle );
}
if ( style.TextGradient.GradientType == GradientInfo.GradientTypes.Radial )
{
Gradient = TextGradient.Radial( colors, stops, 0, new SKPoint( 0.5f, 0.5f ), (RadialSizeMode)style.TextGradient.SizeMode );
}
}
if ( style.TextShadow != null && !style.TextShadow.IsNone )
{
foreach ( var shadow in style.TextShadow )
{
var effect = TextEffect.DropShadow( shadow.Color.ToSk(), shadow.OffsetX, shadow.OffsetY, shadow.Blur );
effect.Width = 0;
effect.BlurSize = MathF.Max( effect.BlurSize, 0.01f );
Style.AddEffect( effect );
var shadowSize = (effect.Width + shadow.Blur) * 2.0f;
EffectMargin.Left = MathF.Max( EffectMargin.Left, shadowSize + -shadow.OffsetX ).CeilToInt();
EffectMargin.Right = MathF.Max( EffectMargin.Right, shadowSize + shadow.OffsetX ).CeilToInt();
EffectMargin.Top = MathF.Max( EffectMargin.Top, shadowSize + -shadow.OffsetY ).CeilToInt();
EffectMargin.Bottom = MathF.Max( EffectMargin.Bottom, shadowSize + shadow.OffsetY ).CeilToInt();
}
}
if ( style.TextStrokeWidth.Value.Value > 0.0f )
{
var color = style.TextStrokeColor ?? style.FontColor ?? Color.Black;
var size = style.TextStrokeWidth.Value.GetPixels( 1.0f );
var effect = TextEffect.Outline( color.ToSk(), size );
effect.StrokeMiter = 2.0f;
effect.StrokeJoin = SKStrokeJoin.Round;
Style.AddEffect( effect );
EffectMargin.Left = MathF.Max( EffectMargin.Left, size ).CeilToInt();
EffectMargin.Right = MathF.Max( EffectMargin.Right, size ).CeilToInt();
EffectMargin.Top = MathF.Max( EffectMargin.Top, size ).CeilToInt();
EffectMargin.Bottom = MathF.Max( EffectMargin.Bottom, size ).CeilToInt();
}
if ( Block == null )
{
Block = new Topten.RichTextKit.TextBlock();
Block.FontMapper = FontManager.Instance;
}
Block.Clear();
Block.Alignment = (Topten.RichTextKit.TextAlignment)TextAlign;
Block.Overflow = (Topten.RichTextKit.TextOverflow)TextOverflow;
Block.WordBreak = (Topten.RichTextKit.WordBreakMode)WordBreak;
Block.NoWrap = NoWrap || WhiteSpace == UI.WhiteSpace.NoWrap;
if ( IsHtml && !string.IsNullOrWhiteSpace( Text ) )
{
try
{
HtmlSpans = new List<HtmlSpan>();
var html = htmlNode;
if ( html is not null )
{
BuildBlockFromHtml( Block, html, Style );
}
if ( LookupStyles is not null )
{
foreach ( var span in HtmlSpans )
{
var s = LookupStyles( span.node );
if ( s is null ) continue;
var sty = Style.Copy();
sty.FontSize = (style.FontSize ?? Length.Pixels( 13 ).Value).GetPixels( 100 );
sty.FontSize = MathF.Round( FontSize * 32.0f ) / 32.0f;
sty.FontFamily = s.FontFamily;
sty.TextColor = s.FontColor?.ToSk() ?? sty.TextColor;
sty.BackgroundColor = s.BackgroundColor?.ToSk() ?? sty.BackgroundColor;
sty.FontWeight = s.FontWeight ?? sty.FontWeight;
sty.FontItalic = s.FontStyle == FontStyle.Italic;
sty.FontVariantNumeric = s.FontVariantNumeric ?? sty.FontVariantNumeric;
sty.Underline = s.TextDecorationLine == UI.TextDecoration.Underline ? UnderlineStyle.Solid : UnderlineStyle.None;
sty.UnderlineColor = sty.TextColor;
sty.LetterSpacing = s.LetterSpacing?.GetPixels( 1000.0f ) ?? sty.LetterSpacing;
sty.WordSpacing = s.WordSpacing?.GetPixels( 1000.0f ) ?? sty.WordSpacing;
sty.StrikeThrough = (s.TextDecorationLine?.Contains( UI.TextDecoration.LineThrough ) ?? false) ? StrikeThroughStyle.Solid : sty.StrikeThrough;
Block.ApplyStyle( span.from, span.to - span.from, sty );
}
}
}
catch ( System.Exception e )
{
Log.Warning( e );
}
}
else
{
Block.AddText( FixedText( Text ), Style );
}
SizeCache.Clear();
ReleaseTexture();
return true;
}
public record class HtmlSpan( INode node, int from, int to );
public List<HtmlSpan> HtmlSpans;
private void BuildBlockFromHtml( Topten.RichTextKit.TextBlock block, Node node, Style style )
{
if ( node.IsComment )
return;
if ( node.IsText )
{
var startText = block.Length;
block.AddText( node.InnerHtml, style );
var endText = block.Length;
var span = new HtmlSpan( node?.ParentNode, startText, endText );
HtmlSpans.Add( span );
}
if ( node.Name == "br" )
{
block.AddText( "\n", style );
return;
}
foreach ( var c in node.ChildNodes )
{
BuildBlockFromHtml( block, c, style );
}
}
void ReleaseTexture()
{
WaitTextureReady();
if ( Texture == null )
return;
LastTexture?.Dispose();
LastTexture = Texture;
Texture = null;
OnTextureChanged?.Invoke();
}
int lastSizeHash = 0;
/// <summary>
/// Called on layout. We should decide here if we actually need to rebuild
/// </summary>
public void SizeFinalized( float width, float height )
{
WaitTextureReady();
width = width.CeilToInt();
height = height.CeilToInt();
int sizeHash = new Vector2( width, height ).GetHashCode();
if ( lastSizeHash != sizeHash )
{
ReleaseTexture();
lastSizeHash = sizeHash;
if ( Text.Length == 0 )
{
BlockSize = new Vector2( Block.MeasuredWidth.CeilToInt().Clamp( 2, 4096 ), Block.MeasuredHeight.CeilToInt().Clamp( 2, 4096 ) );
}
}
if ( Text.Length == 0 )
return;
if ( Texture == null )
{
// threaded
// TextureRebuild = Task.Run( () => RebuildTexture( width, height ) );
// blocking
RebuildTexture( width, height );
}
}
Task TextureRebuild;
/// <summary>
/// Actually recreate the texture
/// </summary>
unsafe void RebuildTexture( float maxwidth, float maxheight )
{
if ( !ui_rendertext )
return;
//Log.Info( $"RenderText: {Text}" );
bool isEmpty = Text.Length == 0;
if ( TextOverflow != TextOverflow.None )
{
Block.MaxWidth = maxwidth;
Block.MaxHeight = maxheight;
}
else
{
Block.MaxWidth = WhiteSpace == UI.WhiteSpace.NoWrap ? null : (maxwidth.CeilToInt() + 1);
}
int width = Block.MeasuredWidth.CeilToInt().Clamp( 2, 4096 );
int height = Block.MeasuredHeight.CeilToInt().Clamp( 2, 4096 );
if ( Style.LetterSpacing < 0 )
width += Math.Abs( (int)MathF.Floor( Style.LetterSpacing ) );
BlockSize = new Vector2( width, height );
IsTruncated = Block.Truncated;
var marginEdge = EffectMargin.EdgeSize;
width += marginEdge.x.CeilToInt();
height += marginEdge.y.CeilToInt();
if ( isEmpty )
return;
if ( Gradient != null && Gradient.GradientType == Topten.RichTextKit.GradientType.Radial )
{
var centerX = GradientInfo.OffsetX.GetPixels( width ) / width;
var centerY = GradientInfo.OffsetY.GetPixels( height ) / height;
Gradient.Center = new SKPoint( centerX, centerY );
}
using var perfScope = Performance.Scope( "TextBlock.RebuildTexture" );
using ( var bitmap = new SKBitmap( width, height, SKColorType.Bgra8888, SKAlphaType.Premul ) )
using ( var canvas = new SKCanvas( bitmap ) )
{
var o = new Topten.RichTextKit.TextPaintOptions
{
Edging = Smooth switch
{
FontSmooth.Never => SKFontEdging.Alias,
_ => SKFontEdging.Antialias,
},
Hinting = SKFontHinting.Full,
TextGradient = Gradient
};
canvas.Clear( Style.TextColor.WithAlpha( 0 ) );
if ( ShouldDrawSelection && (SelectionStart > 0 || SelectionEnd > 0) )
{
o.Selection = new TextRange( CaretToCodePointIndex( SelectionStart ), CaretToCodePointIndex( SelectionEnd ) );
o.SelectionColor = SelectionColor.ToSk();
}
Block.Paint( canvas, new SKPoint( EffectMargin.Left - Block.MeasuredPadding.Left, EffectMargin.Top ), o );
var debugName = Text;
if ( debugName.Length > 10 ) debugName = $"{debugName.Substring( 0, 8 )}..";
if ( debugName.Contains( ':' ) ) debugName = debugName.Replace( ':', '-' );
//
// Make a texture that big
//
int numMips = (int)Math.Log2( Math.Min( width, height ) ) + 1;
if ( LastTexture != null )
{
// we already have a texture that is the right size, lets just use that
if ( LastTexture.Size == new Vector2( width, height ) )
{
var span = new Span<byte>( bitmap.GetPixels().ToPointer(), width * height * bitmap.BytesPerPixel );
LastTexture.Update( span, 0, 0, width, height );
Texture = LastTexture;
LastTexture = null;
OnTextureChanged?.Invoke();
return;
}
LastTexture?.Dispose();
LastTexture = null;
}
Texture = Texture.Create( width, height, ImageFormat.BGRA8888 )
.WithName( $"skiatextblock[{debugName}]" )
.WithMips( numMips )
.WithData( bitmap.GetPixels(), width * height * bitmap.BytesPerPixel )
.WithDynamicUsage()
.Finish();
OnTextureChanged?.Invoke();
}
}
int CaretToCodePointIndex( int caretPos )
{
if ( caretPos < 0 || caretPos > Block.CaretIndicies.Count - 1 )
return caretPos;
return Block.CaretIndicies[caretPos];
}
string FixedText( string text )
{
if ( string.IsNullOrEmpty( text ) ) return ".";
// TODO - if starts with #, look up localized string
//text = text.Replace( "\r", "" );
text = text.Replace( "\r\n", new string( (char)0x2029, 1 ) );
text = text.Replace( '\n', (char)0x2029 ); // replace newlines with paragraph, makes them not render as a square with a cross in it
if ( TextTransform.HasValue )
{
switch ( TextTransform.Value )
{
case UI.TextTransform.Uppercase:
text = text.ToUpperInvariant();
break;
case UI.TextTransform.Lowercase:
text = text.ToLowerInvariant();
break;
case UI.TextTransform.Capitalize:
{
text = System.Threading.Thread.CurrentThread.CurrentCulture.TextInfo.ToTitleCase( text );
break;
}
}
}
text = WhiteSpace switch
{
UI.WhiteSpace.Normal or UI.WhiteSpace.NoWrap => text.CollapseWhiteSpace(),
UI.WhiteSpace.PreLine => text.CollapseSpacesAndPreserveLines(),
UI.WhiteSpace.Pre or UI.WhiteSpace.PreWrap or UI.WhiteSpace.BreakSpaces => text,
_ => throw new Exception( $"Unknown white-space value {WhiteSpace}" ),
};
return text;
}
float GetLineHeightMultiplier()
{
var lineHeight = LineHeight.Value;
if ( lineHeight.Unit == LengthUnit.Percentage )
return lineHeight.GetFraction();
if ( lineHeight.Unit == LengthUnit.Pixels )
return lineHeight.Value / Math.Max( FontSize, 1.0f );
return 1.0f;
}
internal void ScrollToCaret( int caretPosition, ref Vector2 scroll, Vector2 visibleBounds )
{
Rect caretRect = CaretRect( caretPosition - 1 );
if ( caretRect.Left < scroll.x )
{
scroll.x = caretRect.Left;
}
else if ( caretRect.Right > scroll.x + visibleBounds.x )
{
scroll.x = caretRect.Right - visibleBounds.x + caretRect.Width;
}
if ( caretRect.Top < scroll.y )
{
scroll.y = caretRect.Top;
}
else if ( caretRect.Bottom > scroll.y + visibleBounds.y )
{
scroll.y = caretRect.Bottom - visibleBounds.y + caretRect.Height;
}
}
public void Dispose()
{
ReleaseTexture();
LastTexture?.Dispose();
LastTexture = null;
Block = null;
Style = null;
SizeCache = null;
}
}