mirror of
https://github.com/Facepunch/sbox-public.git
synced 2026-08-04 09:43:37 -04:00
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
668 lines
15 KiB
C#
668 lines
15 KiB
C#
using Microsoft.AspNetCore.Components;
|
|
using Sandbox.Internal;
|
|
using Sandbox.Rendering;
|
|
using System.Threading;
|
|
|
|
namespace Sandbox.UI;
|
|
|
|
/// <summary>
|
|
/// A simple User Interface panel. Can be styled with <a href="https://en.wikipedia.org/wiki/CSS">CSS</a>.
|
|
/// </summary>
|
|
[Library( "panel" ), Alias( "div", "span" ), Expose]
|
|
[Title( "Panel" ), Icon( "view_quilt" )]
|
|
public partial class Panel : IPanel, IValid, IComponent
|
|
{
|
|
/// <summary>
|
|
/// The element name. If you've created this Panel via a template this will be whatever the element
|
|
/// name is on there. If not then it'll be the name of the class (ie Panel, Button)
|
|
/// </summary>
|
|
[Property]
|
|
public string ElementName { get; set; }
|
|
|
|
/// <summary>
|
|
/// Works the same as the html id="" attribute. If you set Id to "poop", it'll match any styles
|
|
/// that define #poop in their selector.
|
|
/// </summary>
|
|
public string Id
|
|
{
|
|
get;
|
|
set;
|
|
}
|
|
|
|
/// <summary>
|
|
/// If this was created by razor, this is the file in which it was created
|
|
/// </summary>
|
|
[Hide]
|
|
public string SourceFile { get; set; }
|
|
|
|
/// <summary>
|
|
/// If this was created by razor, this is the line number in the file
|
|
/// </summary>
|
|
[Hide]
|
|
public int SourceLine { get; set; }
|
|
|
|
/// <summary>
|
|
/// Quick access to timing events, for async/await.
|
|
/// </summary>
|
|
public TaskSource Task = new( 1 );
|
|
|
|
/// <summary>
|
|
/// A collection of stylesheets applied to this panel directly.
|
|
/// </summary>
|
|
public StyleSheetCollection StyleSheet;
|
|
|
|
|
|
//
|
|
// Start with the intro flag on
|
|
//
|
|
PseudoClass _pseudoClass = PseudoClass.Intro;
|
|
|
|
/// <summary>
|
|
/// Special flags used by the styling system for hover, active etc..
|
|
/// </summary>
|
|
[Property]
|
|
public PseudoClass PseudoClass
|
|
{
|
|
get => _pseudoClass;
|
|
set
|
|
{
|
|
if ( _pseudoClass == value )
|
|
return;
|
|
|
|
_pseudoClass = value;
|
|
|
|
StyleSelectorsChanged( true, true );
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether this panel has the <c>:focus</c> pseudo class active.
|
|
/// </summary>
|
|
[Hide]
|
|
public bool HasFocus => (PseudoClass & PseudoClass.Focus) != 0;
|
|
|
|
/// <summary>
|
|
/// Whether this panel has the <c>:active</c> pseudo class active.
|
|
/// </summary>
|
|
[Hide]
|
|
public bool HasActive => (PseudoClass & PseudoClass.Active) != 0;
|
|
|
|
/// <summary>
|
|
/// Whether this panel has the <c>:hover</c> pseudo class active.
|
|
/// </summary>
|
|
[Hide]
|
|
public bool HasHovered => (PseudoClass & PseudoClass.Hover) != 0;
|
|
|
|
/// <summary>
|
|
/// Whether this panel has the <c>:intro</c> pseudo class active.
|
|
/// </summary>
|
|
[Hide]
|
|
public bool HasIntro => (PseudoClass & PseudoClass.Intro) != 0;
|
|
|
|
/// <summary>
|
|
/// Whether this panel has the <c>:outro</c> pseudo class active.
|
|
/// </summary>
|
|
[Hide]
|
|
public bool HasOutro => (PseudoClass & PseudoClass.Outro) != 0;
|
|
|
|
|
|
public Panel()
|
|
{
|
|
InitializeEvents();
|
|
|
|
YogaNode = new YogaWrapper( this );
|
|
Style = new PanelStyle( this );
|
|
StyleSheet = new StyleSheetCollection( this );
|
|
Transitions = new Transitions( this );
|
|
LayerCommandList = new CommandList( $"UI Layer: {GetType().Name}" );
|
|
|
|
ElementName = GetType().Name.ToLower();
|
|
Switch( PseudoClass.Empty, true );
|
|
|
|
LoadStyleSheet();
|
|
}
|
|
|
|
public Panel( Panel parent ) : this()
|
|
{
|
|
if ( parent != null )
|
|
Parent = parent;
|
|
}
|
|
|
|
public Panel( Panel parent, string classnames ) : this( parent )
|
|
{
|
|
if ( classnames != null )
|
|
AddClass( classnames );
|
|
}
|
|
|
|
internal virtual void AddToLists()
|
|
{
|
|
Sandbox.Event.Register( this );
|
|
}
|
|
|
|
internal virtual void RemoveFromLists()
|
|
{
|
|
Sandbox.Event.Unregister( this );
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when a hotload happened. (Not necessarily on this panel)
|
|
/// </summary>
|
|
public virtual void OnHotloaded()
|
|
{
|
|
LoadStyleSheet();
|
|
InitializeEvents();
|
|
|
|
// If the checksum changed on our render tree, then we have to assume that everthing
|
|
// about it changed. Lets destroy it and start from scratch.
|
|
if ( razorLastTreeChecksum != GetRenderTreeChecksum() )
|
|
{
|
|
razorLastTreeChecksum = GetRenderTreeChecksum();
|
|
if ( renderTree != null )
|
|
{
|
|
renderTree?.Clear();
|
|
razorTreeDirty = true;
|
|
}
|
|
}
|
|
|
|
//
|
|
// Some of our children may have stopped existing. Like if they deleted the
|
|
// type - then we'll have nulls here. Lets prune them out.
|
|
//
|
|
_children?.RemoveAll( x => x is null );
|
|
_renderChildren?.RemoveAll( x => x is null );
|
|
|
|
if ( _childrenHash is not null && _childrenHash.Any( x => x == null ) )
|
|
_childrenHash = _childrenHash.Where( x => x != null ).ToHashSet();
|
|
|
|
foreach ( var child in Children )
|
|
{
|
|
try
|
|
{
|
|
child.OnHotloaded();
|
|
}
|
|
catch ( System.Exception e )
|
|
{
|
|
Log.Error( e );
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// List of all <see cref="UI.StyleSheet"/>s applied to this panel and all its <see cref="AncestorsAndSelf">ancestors</see>.
|
|
/// </summary>
|
|
[Hide]
|
|
public IEnumerable<StyleSheet> AllStyleSheets
|
|
{
|
|
get
|
|
{
|
|
foreach ( var p in AncestorsAndSelf )
|
|
{
|
|
if ( p.StyleSheet.List == null ) continue;
|
|
|
|
foreach ( var sheet in p.StyleSheet.List )
|
|
yield return sheet;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Switch a pseudo class on or off.
|
|
/// </summary>
|
|
public bool Switch( PseudoClass c, bool state )
|
|
{
|
|
if ( state == ((PseudoClass & c) != 0) ) return false;
|
|
|
|
if ( state )
|
|
{
|
|
PseudoClass |= c;
|
|
}
|
|
else
|
|
{
|
|
PseudoClass &= ~c;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
internal static void Switch( PseudoClass c, bool state, Panel panel, Panel unlessAncestorOf = null )
|
|
{
|
|
if ( panel == null )
|
|
return;
|
|
|
|
foreach ( var target in panel.AncestorsAndSelf )
|
|
{
|
|
if ( unlessAncestorOf != null && unlessAncestorOf.IsAncestor( target ) )
|
|
continue;
|
|
|
|
target.Switch( c, state );
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Return true if this panel isn't hidden by opacity or displaymode.
|
|
/// </summary>
|
|
[Hide]
|
|
public bool IsVisible { get; internal set; } = true;
|
|
|
|
/// <summary>
|
|
/// Return true if this panel isn't hidden by opacity or displaymode.
|
|
/// </summary>
|
|
[Hide]
|
|
public bool IsVisibleSelf { get; internal set; } = true;
|
|
|
|
/// <summary>
|
|
/// Called every frame. This is your "Think" function.
|
|
/// </summary>
|
|
public virtual void Tick()
|
|
{
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called after the parent of this panel has changed.
|
|
/// </summary>
|
|
public virtual void OnParentChanged()
|
|
{
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if this panel would like the mouse cursor to be visible.
|
|
/// </summary>
|
|
public virtual bool WantsMouseInput()
|
|
{
|
|
if ( ComputedStyle == null )
|
|
return false;
|
|
|
|
if ( !IsVisibleSelf )
|
|
return false;
|
|
|
|
if ( ComputedStyle.PointerEvents == PointerEvents.All )
|
|
return true;
|
|
|
|
if ( _children is null )
|
|
return false;
|
|
|
|
foreach ( var child in _children )
|
|
{
|
|
if ( child?.WantsMouseInput() ?? false )
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
internal void TickInternal()
|
|
{
|
|
if ( Application.IsHeadless )
|
|
return;
|
|
|
|
if ( IsDeleting )
|
|
{
|
|
// we're probably transitioning out
|
|
// so make sure we keep updating the layout
|
|
SetNeedsPreLayout();
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if ( ParentHasChanged )
|
|
{
|
|
ParentHasChanged = false;
|
|
OnParentChanged();
|
|
|
|
// Our ancestor stylesheets have changed, so our candidate rules need rebuilding
|
|
Style?.InvalidateBroadphase();
|
|
StyleSelectorsChanged( true, true );
|
|
}
|
|
|
|
var didBuildRenderTree = false;
|
|
var isFirstRender = renderTree == null;
|
|
|
|
if ( HasRenderTree || templateBindsChanged )
|
|
{
|
|
InternalTreeBinds();
|
|
|
|
if ( templateBindsChanged )
|
|
{
|
|
templateBindsChanged = false;
|
|
razorTreeDirty = true;
|
|
ParametersChanged( true );
|
|
}
|
|
|
|
if ( razorTreeDirty )
|
|
{
|
|
InternalRenderTree();
|
|
didBuildRenderTree = true;
|
|
}
|
|
}
|
|
|
|
// keep before and after updated
|
|
UpdateBeforeAfterElements();
|
|
|
|
//
|
|
// If our style is dirty, or we're animating/transitioning/scrolling then make sure we get layed out
|
|
//
|
|
if ( Style is not null && (Style.IsDirty || HasActiveTransitions || (ComputedStyle?.IsAnimationActive ?? false) || ScrollVelocity != 0 || isScrolling || IsDragScrolling) )
|
|
{
|
|
SetNeedsPreLayout();
|
|
}
|
|
|
|
//
|
|
// Don't tick our children if we're not visible
|
|
//
|
|
if ( IsVisible && _children is not null && _children.Count > 0 )
|
|
{
|
|
for ( int i = _children.Count - 1; _children != null && i >= 0; i-- )
|
|
{
|
|
_children[i]?.TickInternal();
|
|
}
|
|
}
|
|
|
|
//
|
|
// Defer OnAfterTreeRender so that children are all processed too
|
|
//
|
|
if ( didBuildRenderTree )
|
|
{
|
|
OnAfterTreeRender( isFirstRender );
|
|
}
|
|
|
|
RunPendingEvents();
|
|
Tick();
|
|
RunPendingEvents();
|
|
|
|
AddScrollVelocity();
|
|
RunClassBinds();
|
|
|
|
|
|
}
|
|
catch ( System.Exception e )
|
|
{
|
|
Log.Error( e );
|
|
}
|
|
|
|
}
|
|
|
|
internal int GetRenderOrderIndex()
|
|
{
|
|
return (SiblingIndex) + (ComputedStyle?.ZIndex ?? 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert a point from the screen to a point representing a delta on this panel where
|
|
/// the top left is [0,0] and the bottom right is [1,1]
|
|
/// </summary>
|
|
public Vector2 ScreenPositionToPanelDelta( Vector2 pos )
|
|
{
|
|
pos = ScreenPositionToPanelPosition( pos );
|
|
|
|
var x = pos.x.LerpInverse( 0, Box.Rect.Width, false );
|
|
var y = pos.y.LerpInverse( 0, Box.Rect.Height, false );
|
|
|
|
return new Vector2( x, y );
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert a point from the screen to a position relative to the top left of this panel
|
|
/// </summary>
|
|
public Vector2 ScreenPositionToPanelPosition( Vector2 pos )
|
|
{
|
|
if ( GlobalMatrix.HasValue )
|
|
{
|
|
pos = GlobalMatrix.Value.Transform( pos );
|
|
}
|
|
|
|
var x = pos.x - Box.Rect.Left;
|
|
var y = pos.y - Box.Rect.Top;
|
|
|
|
return new Vector2( x, y );
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert a point from local space to screen space
|
|
/// </summary>
|
|
public Vector2 PanelPositionToScreenPosition( Vector2 pos )
|
|
{
|
|
var screenPos = new Vector2( pos.x + Box.Rect.Left, pos.y + Box.Rect.Top );
|
|
|
|
if ( GlobalMatrix.HasValue )
|
|
{
|
|
screenPos = GlobalMatrix.Value.Inverted.Transform( screenPos );
|
|
}
|
|
|
|
return screenPos;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Find and return any children of this panel (including self) within the given rect.
|
|
/// </summary>
|
|
/// <param name="box">The area to look for panels in, in screen-space coordinates.</param>
|
|
/// <param name="fullyInside">Whether we want only the panels that are completely within the given bounds.</param>
|
|
public IEnumerable<Panel> FindInRect( Rect box, bool fullyInside )
|
|
{
|
|
if ( !IsVisible )
|
|
yield break;
|
|
|
|
if ( !IsInside( box, fullyInside ) )
|
|
yield break;
|
|
|
|
yield return this;
|
|
|
|
if ( !HasChildren )
|
|
yield break;
|
|
|
|
foreach ( var child in Children )
|
|
{
|
|
foreach ( var found in child.FindInRect( box, fullyInside ) )
|
|
{
|
|
yield return found;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Allow selecting child text
|
|
/// </summary>
|
|
[Category( "Selection" )]
|
|
public bool AllowChildSelection { get; set; }
|
|
|
|
[Hide]
|
|
public bool IsValid => YogaNode is not null;
|
|
|
|
string CollectSelectedChildrenText( Panel p )
|
|
{
|
|
if ( !p.IsVisible )
|
|
return null;
|
|
|
|
if ( p is Sandbox.UI.Label label )
|
|
{
|
|
return label.GetSelectedText();
|
|
}
|
|
|
|
string selection = null;
|
|
|
|
var lines = p.ComputedStyle.FlexDirection == FlexDirection.Column;
|
|
|
|
foreach ( var child in p.Children )
|
|
{
|
|
var sel = CollectSelectedChildrenText( child );
|
|
if ( string.IsNullOrEmpty( sel ) ) continue;
|
|
|
|
if ( selection == null ) selection = sel;
|
|
else selection = $"{selection}{(lines ? "\n" : " ")}{sel}";
|
|
}
|
|
|
|
return selection;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when the player moves the mouse after "press and holding" (or dragging) the panel.
|
|
/// </summary>
|
|
protected virtual void OnDragSelect( SelectionEvent e )
|
|
{
|
|
if ( AllowChildSelection )
|
|
{
|
|
e.StopPropagation();
|
|
|
|
foreach ( var child in Children )
|
|
{
|
|
UpdateSelection( child, e );
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// If AllowChildSelection is enabled, we'll try to select all children text
|
|
/// </summary>
|
|
public void SelectAllInChildren()
|
|
{
|
|
if ( this is Sandbox.UI.Label label )
|
|
{
|
|
label.ShouldDrawSelection = true;
|
|
label.SelectionStart = 0;
|
|
label.SelectionEnd = int.MaxValue;
|
|
return;
|
|
}
|
|
|
|
foreach ( var child in Children )
|
|
{
|
|
child.SelectAllInChildren();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear any selection in children
|
|
/// </summary>
|
|
public void UnselectAllInChildren()
|
|
{
|
|
if ( this is Sandbox.UI.Label label )
|
|
{
|
|
label.ShouldDrawSelection = false;
|
|
return;
|
|
}
|
|
|
|
foreach ( var child in Children )
|
|
{
|
|
child.UnselectAllInChildren();
|
|
}
|
|
}
|
|
|
|
void UpdateSelection( Panel p, SelectionEvent e )
|
|
{
|
|
var rect = e.SelectionRect;
|
|
|
|
// child is outside of selection vertically
|
|
if ( p.Box.Rect.Bottom < rect.Top || p.Box.Rect.Top > rect.Bottom )
|
|
{
|
|
p.UnselectAllInChildren();
|
|
return;
|
|
}
|
|
|
|
// Selectable
|
|
if ( p is Sandbox.UI.Label label )
|
|
{
|
|
label.ShouldDrawSelection = true;
|
|
|
|
if ( e.StartPoint.y > e.EndPoint.y )
|
|
{
|
|
(e.EndPoint, e.StartPoint) = (e.StartPoint, e.EndPoint);
|
|
}
|
|
|
|
var start = p.Box.Rect.Top < rect.Top;
|
|
var end = p.Box.Rect.Bottom > e.EndPoint.y;
|
|
var negwidth = (e.EndPoint - e.StartPoint).x < 0;
|
|
|
|
// selectAll
|
|
if ( start && end )
|
|
{
|
|
label.SelectionStart = label.GetLetterAtScreenPosition( new Vector2( rect.Left, rect.Top ) );
|
|
label.SelectionEnd = label.GetLetterAtScreenPosition( new Vector2( rect.Right, rect.Bottom ) );
|
|
}
|
|
else if ( start )
|
|
{
|
|
var from = negwidth ? rect.Right : rect.Left;
|
|
label.SelectionStart = label.GetLetterAtScreenPosition( new Vector2( from, rect.Top ) );
|
|
label.SelectionEnd = int.MaxValue;
|
|
}
|
|
else if ( end )
|
|
{
|
|
var to = negwidth ? rect.Left : rect.Right;
|
|
label.SelectionStart = 0;
|
|
label.SelectionEnd = label.GetLetterAtScreenPosition( new Vector2( to, rect.Bottom ) );
|
|
}
|
|
else
|
|
{
|
|
label.SelectionStart = 0;
|
|
label.SelectionEnd = int.MaxValue;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
foreach ( var child in p.Children )
|
|
{
|
|
UpdateSelection( child, e );
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when the current language has changed. This allows you to rebuild
|
|
/// anything that might need rebuilding. Tokenized text labels should automatically update.
|
|
/// </summary>
|
|
public virtual void LanguageChanged()
|
|
{
|
|
foreach ( var child in Children )
|
|
{
|
|
child.LanguageChanged();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invoke a method after a delay. If the panel is deleted before this delay the method will not be called.
|
|
/// </summary>
|
|
public async void Invoke( float seconds, Action action )
|
|
{
|
|
await Task.DelaySeconds( seconds );
|
|
if ( !this.IsValid() ) return;
|
|
|
|
action.InvokeWithWarning();
|
|
}
|
|
|
|
Dictionary<string, CancellationTokenSource> invokes = new();
|
|
|
|
/// <summary>
|
|
/// Invoke a method after a delay. If the panel is deleted before this delay the method will not be called. If the invoke is called
|
|
/// while the old one is waiting, the old one will be cancelled.
|
|
/// </summary>
|
|
public async void InvokeOnce( string name, float seconds, Action action )
|
|
{
|
|
CancelInvoke( name );
|
|
|
|
var tokenSource = new CancellationTokenSource();
|
|
invokes[name] = tokenSource;
|
|
|
|
await Task.DelaySeconds( seconds );
|
|
if ( !this.IsValid() ) return;
|
|
|
|
if ( tokenSource.IsCancellationRequested )
|
|
return;
|
|
|
|
invokes.Remove( name );
|
|
action.InvokeWithWarning();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancel a named invocation
|
|
/// </summary>
|
|
public void CancelInvoke( string name )
|
|
{
|
|
if ( invokes.Remove( name, out var cts ) )
|
|
{
|
|
cts.Cancel();
|
|
cts.Dispose();
|
|
}
|
|
}
|
|
|
|
}
|