Files
sbox-public/engine/Sandbox.Engine/Systems/UI/Controls/Label.cs

514 lines
11 KiB
C#

using Sandbox.Html;
using System.Globalization;
namespace Sandbox.UI
{
/// <summary>
/// A generic text label. Can be made editable.
/// </summary>
[Library( "label" ), Alias( "text" ), Expose]
public partial class Label : Panel
{
/// <summary>
/// Information about the <see cref="Text"/> on a per-element scale. It handles multi-character Unicode units (graphemes) correctly.
/// </summary>
protected StringInfo StringInfo = new();
internal string _textToken;
internal string _text;
internal Rect _textRect;
internal TextBlock _textBlock;
int layoutStateHash;
bool sizeFinalized;
Vector2 availableSpace;
public override bool HasContent => true;
[Category( "Selection" )]
public bool ShouldDrawSelection
{
get => _textBlock?.ShouldDrawSelection ?? false;
set
{
if ( _textBlock is null )
return;
if ( _textBlock.ShouldDrawSelection == Selectable && value )
return;
_textBlock.ShouldDrawSelection = Selectable && value;
SetNeedsPreLayout();
}
}
/// <summary>
/// Can be selected
/// </summary>
[Category( "Selection" )]
public bool Selectable { get; set; } = true;
/// <summary>
/// If true and the text starts with #, it will be treated as a language token.
/// </summary>
public bool Tokenize { get; set; } = true;
[Hide]
public int SelectionStart
{
get => _textBlock?.SelectionStart ?? 0;
set
{
if ( _textBlock == null ) return;
if ( _textBlock.SelectionStart == value ) return;
_textBlock.SelectionStart = value;
SetNeedsPreLayout();
}
}
[Hide]
public int SelectionEnd
{
get => _textBlock?.SelectionEnd ?? 0;
set
{
if ( _textBlock == null ) return;
if ( _textBlock.SelectionEnd == value ) return;
_textBlock.SelectionEnd = value;
SetNeedsPreLayout();
}
}
/// <summary>
/// The color used for text selection highlight
/// </summary>
[Category( "Selection" )]
public Color SelectionColor
{
get => _textBlock?.SelectionColor ?? Color.Cyan.WithAlpha( 0.39f );
set
{
if ( _textBlock == null ) return;
if ( _textBlock.SelectionColor == value ) return;
_textBlock.SelectionColor = value;
}
}
public Label()
{
AddClass( "label" );
YogaNode.SetMeasureFunction( MeasureText );
}
public Label( string text, string classname = null ) : this()
{
Text = text;
AddClass( classname );
}
Vector2 MeasureText( YGNodeRef node, float width, YGMeasureMode widthMode, float height, YGMeasureMode heightMode )
{
try
{
if ( _textBlock == null ) return new Vector2( 2, 10 );
availableSpace = new Vector2( width, height );
Vector2 size;
if ( sizeFinalized && _textBlock.IsTruncated )
{
size = _textBlock.BlockSize;
}
else
{
size = _textBlock.Measure( width, height );
}
return size;
}
catch ( System.Exception e )
{
NativeEngine.EngineGlobal.Plat_MessageBox( e.Message, e.StackTrace );
return default;
}
}
public override void OnDeleted()
{
base.OnDeleted();
_textBlock?.Dispose();
_textBlock = null;
}
/// <summary>
/// Text to display on the label.
/// </summary>
public virtual string Text
{
get => _text;
set
{
value ??= "";
if ( Tokenize && value != null && value.Length > 1 && value[0] == '#' )
{
if ( _textToken == value ) return;
_textToken = value;
value = Language.GetPhrase( _textToken[1..] );
}
if ( _text == value )
return;
_text = value;
StringInfo.String = value ?? string.Empty;
CaretSantity();
SetNeedsPreLayout();
}
}
/// <summary>
/// Set to true if this is rich text. This means it can support some inline html elements.
/// </summary>
public bool IsRich { get; set; }
public override void SetProperty( string name, string value )
{
if ( name == "text" )
{
Text = value;
return;
}
if ( name == "selectable" )
{
//Selectable = value.ToBool();
return;
}
base.SetProperty( name, value );
}
public override void SetContent( string value )
{
// alex: This value gets trimmed inside TextBlock based on the WhiteSpace
// style value for this label
Text = value ?? "";
}
/// <summary>
/// Position of the text cursor/caret within the text, at which newly typed characters are inserted.
/// </summary>
public int CaretPosition { get; set; }
/// <summary>
/// Amount of characters in the text of the text entry. Not bytes.
/// </summary>
public int TextLength => StringInfo.LengthInTextElements;
/// <summary>
/// Ensure the text caret and selection are in sane positions, that is, not outside of the text bounds.
/// </summary>
protected void CaretSantity()
{
if ( CaretPosition > TextLength )
{
CaretPosition = TextLength;
ScrollToCaret();
}
if ( SelectionStart > TextLength )
{
SelectionStart = TextLength;
ScrollToCaret();
}
if ( SelectionEnd > TextLength )
{
SelectionEnd = TextLength;
ScrollToCaret();
}
}
/// <summary>
/// Returns the selected text.
/// </summary>
public string GetSelectedText()
{
if ( TextLength == 0 ) return "";
if ( !HasSelection() ) return "";
CaretSantity();
var s = Math.Min( SelectionStart, SelectionEnd );
var e = Math.Max( SelectionStart, SelectionEnd );
return StringInfo.SubstringByTextElements( s, e - s );
}
public override string GetClipboardValue( bool cut )
{
if ( !HasSelection() )
return null;
var txt = GetSelectedText();
return txt;
}
public Rect GetCaretRect( int i )
{
var rect = _textBlock.CaretRect( i );
rect.Position += _textRect.Position - caretScroll;
rect.Width = 2;
return rect;
}
internal override void PreLayout( LayoutCascade cascade )
{
base.PreLayout( cascade );
string styleContent = null;
if ( ComputedStyle.Content != null )
{
styleContent = ComputedStyle.Content;
if ( styleContent.Length > 1 && styleContent[0] == '#' )
{
styleContent = Language.GetPhrase( styleContent[1..] );
}
}
var text = styleContent ?? Text ?? string.Empty;
if ( _textBlock is null )
{
_textBlock = new TextBlock();
_textBlock.LookupStyles = HtmlStyleLookup;
}
_textBlock.NoWrap = !Multiline;
if ( IsRich )
{
_textBlock.SetHtml( text );
_textBlock.NoWrap = false;
}
else
{
_textBlock.SetText( text );
}
int newStateHash = HashCode.Combine( (int)(availableSpace.x * 100), ScaleToScreen, _textBlock.IsTruncated, hoveredNode );
if ( newStateHash != layoutStateHash )
{
layoutStateHash = newStateHash;
sizeFinalized = false;
}
if ( _textBlock.UpdateStyles( ComputedStyle ) )
{
YogaNode.MarkDirty();
sizeFinalized = false;
}
}
private Styles HtmlStyleLookup( INode node )
{
if ( node.GetAttribute( "style", null ) is string styles )
{
Log.Warning( "TODO: Apply Html Styles" );
}
var blocks = AllStyleSheets
.SelectMany( x => x.Nodes )
.Select( x => x.Test( node ) )
.Where( x => x is not null )
.ToList();
if ( blocks.Count == 0 )
return null;
blocks.Sort( StyleOrderer.Instance );
var s = new Styles();
foreach ( var entry in blocks )
{
s.Add( entry.Block.Styles );
}
return s;
}
public override void FinalLayout( Vector2 offset )
{
base.FinalLayout( offset );
if ( !IsVisible ) return;
if ( ComputedStyle is null ) return;
_textBlock?.SizeFinalized( Box.RectInner.Width, Box.RectInner.Height );
if ( !sizeFinalized )
{
sizeFinalized = true;
YogaNode.MarkDirty();
}
_textRect = Box.RectInner;
if ( ComputedStyle.TextAlign == TextAlign.Center )
{
_textRect.Left += (_textRect.Width - _textBlock.BlockSize.x) * 0.5f;
}
else if ( ComputedStyle.TextAlign == TextAlign.Right )
{
_textRect.Left = _textRect.Right - _textBlock.BlockSize.x;
}
if ( ComputedStyle.AlignItems == Align.Center )
{
_textRect.Top += (_textRect.Height - _textBlock.BlockSize.y) * 0.5f;
}
else if ( ComputedStyle.AlignItems == Align.FlexEnd )
{
_textRect.Top = _textRect.Bottom - _textBlock.BlockSize.y;
}
_textRect.Size = _textBlock.BlockSize;
}
internal override void DrawContent( PanelRenderer renderer, ref RenderState state )
{
var rect = Box.RectInner;
rect.Position -= caretScroll;
_textBlock?.Render( renderer, ref state, ComputedStyle, rect, Opacity * state.RenderOpacity );
}
public int GetLetterAt( Vector2 pos )
{
if ( _textBlock == null ) return -1;
return _textBlock.GetLetterAt( pos );
}
public int GetLetterAtScreenPosition( Vector2 pos ) => GetLetterAt( ScreenPositionToTextRectPosition( pos ) );
Vector2 ScreenPositionToTextRectPosition( Vector2 pos )
{
if ( GlobalMatrix.HasValue )
{
pos = GlobalMatrix.Value.Transform( pos );
}
var x = pos.x - _textRect.Left;
var y = pos.y - _textRect.Top;
return new Vector2( x, y ) + caretScroll;
}
public bool HasSelection() => ShouldDrawSelection && SelectionStart != SelectionEnd;
/// <summary>
/// When the language changes, if we're token based we need to update to the new phrase.
/// </summary>
public override void LanguageChanged()
{
if ( _textToken == null ) return;
if ( !Tokenize ) return;
var token = _textToken;
_textToken = null; // skip cache
Text = token;
}
INode hoveredNode;
protected override void OnMouseMove( MousePanelEvent e )
{
base.OnMouseMove( e );
if ( _textBlock is null || !IsRich )
{
hoveredNode = default;
return;
}
var hov = _textBlock.GetSpanAt( e.LocalPosition )?.node;
if ( hov == hoveredNode ) return;
if ( hoveredNode is not null )
{
hoveredNode.SetPseudoClass( PseudoClass.None );
}
hoveredNode = hov;
if ( hoveredNode is not null )
{
hoveredNode.SetPseudoClass( PseudoClass.Hover );
}
Style.Cursor = (hoveredNode?.Name == "a") ? "pointer" : null;
_textBlock.Dirty();
SetNeedsPreLayout();
}
protected override void OnClick( MousePanelEvent e )
{
base.OnClick( e );
if ( hoveredNode is not null && hoveredNode.GetAttribute( "href", null ) is { } url )
{
bool isValid = Uri.TryCreate( url, UriKind.Absolute, out var parsedUri ) && (parsedUri.Scheme == "http" || parsedUri.Scheme == "https");
if ( !isValid )
{
Log.Warning( $"Blocked URL: {url}" );
return;
}
//
// Modal popup, are you sure etc?
//
System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo()
{
FileName = parsedUri.ToString(),
UseShellExecute = true,
Verb = "open"
} );
}
}
}
namespace Construct
{
public static class LabelConstructor
{
/// <summary>
/// Create a simple text label with given text and CSS classname.
/// </summary>
public static Label Label( this PanelCreator self, string text = null, string classname = null )
{
var control = self.panel.AddChild<Label>();
if ( text != null )
control.Text = text;
if ( classname != null )
control.AddClass( classname );
return control;
}
}
}
}