// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.UserInterface; using osu.Framework.IO.Stores; using osu.Framework.Layout; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Framework.Text; using osuTK; using osuTK.Graphics; namespace osu.Framework.Graphics.Sprites { /// /// A container for simple text rendering purposes. If more complex text rendering is required, use instead. /// public partial class SpriteText : Drawable, IHasLineBaseHeight, ITexturedShaderDrawable, IHasText, IHasFilterTerms, IFillFlowContainer, IHasCurrentValue { private const float default_text_size = 20; /// /// U+00A0 is the Unicode NON-BREAKING SPACE character (distinct from the standard ASCII space). /// private static readonly char[] default_never_fixed_width_characters = { '.', ',', ':', ' ', '\u00A0' }; [Resolved] private FontStore store { get; set; } [Resolved] private LocalisationManager localisation { get; set; } private ILocalisedBindableString localisedText; public IShader TextureShader { get; private set; } public IShader RoundedTextureShader { get; private set; } public SpriteText() { current.BindValueChanged(text => { // importantly, to avoid a feedback loop which will overwrite a localised text object, check equality of the resulting text before propagating a basic string to Text. // in the case localisedText is not yet setup, special consideration does not need to be given as it can be assumed the change to current was a user invoked change. if (localisedText == null || text.NewValue != localisedText.Value) Text = text.NewValue; }); AddLayout(charactersCache); AddLayout(parentScreenSpaceCache); AddLayout(localScreenSpaceCache); AddLayout(shadowOffsetCache); AddLayout(textBuilderCache); } [BackgroundDependencyLoader] private void load(ShaderManager shaders) { localisedText = localisation.GetLocalisedString(text); TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE); RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); // Pre-cache the characters in the texture store foreach (var character in localisedText.Value) { var unused = store.Get(font.FontName, character) ?? store.Get(null, character); } } protected override void LoadComplete() { base.LoadComplete(); localisedText.BindValueChanged(str => { current.Value = localisedText.Value; if (string.IsNullOrEmpty(str.NewValue)) { // We'll become not present and won't update the characters to set the size to 0, so do it manually if (requiresAutoSizedWidth) base.Width = Padding.TotalHorizontal; if (requiresAutoSizedHeight) base.Height = Padding.TotalVertical; } invalidate(true); }, true); } private LocalisableString text = string.Empty; /// /// Gets or sets the text to be displayed. /// public LocalisableString Text { get => text; set { if (text.Equals(value)) return; text = value; if (localisedText != null) { localisedText.Text = value; } } } private readonly BindableWithCurrent current = new BindableWithCurrent(); public Bindable Current { get => current.Current; set => current.Current = value; } private string displayedText => localisedText?.Value ?? text.ToString(); private FontUsage font = FontUsage.Default; /// /// Contains information on the font used to display the text. /// public FontUsage Font { get => font; set { font = value; invalidate(true, true); shadowOffsetCache.Invalidate(); } } private bool allowMultiline = true; /// /// True if the text should be wrapped if it gets too wide. Note that \n does NOT cause a line break. If you need explicit line breaks, use instead. /// /// /// If enabled, will be disabled. /// public bool AllowMultiline { get => allowMultiline; set { if (allowMultiline == value) return; if (value) Truncate = false; allowMultiline = value; invalidate(true, true); } } private bool shadow; /// /// True if a shadow should be displayed around the text. /// public bool Shadow { get => shadow; set { if (shadow == value) return; shadow = value; Invalidate(Invalidation.DrawNode); } } private Color4 shadowColour = new Color4(0, 0, 0, 0.2f); /// /// The colour of the shadow displayed around the text. A shadow will only be displayed if the property is set to true. /// public Color4 ShadowColour { get => shadowColour; set { if (shadowColour == value) return; shadowColour = value; Invalidate(Invalidation.DrawNode); } } private Vector2 shadowOffset = new Vector2(0, 0.06f); /// /// The offset of the shadow displayed around the text. A shadow will only be displayed if the property is set to true. /// public Vector2 ShadowOffset { get => shadowOffset; set { if (shadowOffset == value) return; shadowOffset = value; invalidate(true); shadowOffsetCache.Invalidate(); } } private bool useFullGlyphHeight = true; /// /// True if the 's vertical size should be equal to (the full height) or precisely the size of used characters. /// Set to false to allow better centering of individual characters/numerals/etc. /// public bool UseFullGlyphHeight { get => useFullGlyphHeight; set { if (useFullGlyphHeight == value) return; useFullGlyphHeight = value; invalidate(true, true); } } private bool truncate; /// /// If true, text should be truncated when it exceeds the of this . /// /// /// Has no effect if no or custom sizing is set. /// If enabled, will be disabled. /// public bool Truncate { get => truncate; set { if (truncate == value) return; if (value) AllowMultiline = false; truncate = value; invalidate(true, true); } } private string ellipsisString = "…"; /// /// When is enabled, this decides what string is used to signify that truncation has occured. /// Defaults to "…". /// public string EllipsisString { get => ellipsisString; set { if (ellipsisString == value) return; ellipsisString = value; invalidate(true, true); } } private bool requiresAutoSizedWidth => explicitWidth == null && (RelativeSizeAxes & Axes.X) == 0; private bool requiresAutoSizedHeight => explicitHeight == null && (RelativeSizeAxes & Axes.Y) == 0; private float? explicitWidth; /// /// Gets or sets the width of this . The will maintain this width when set. /// public override float Width { get { if (requiresAutoSizedWidth) computeCharacters(); return base.Width; } set { if (explicitWidth == value) return; base.Width = value; explicitWidth = value; invalidate(true, true); } } private float maxWidth = float.PositiveInfinity; /// /// The maximum width of this . Affects both auto and fixed sizing modes. /// /// /// This becomes a relative value if this is relatively-sized on the X-axis. /// public float MaxWidth { get => maxWidth; set { if (maxWidth == value) return; maxWidth = value; invalidate(true, true); } } private float? explicitHeight; /// /// Gets or sets the height of this . The will maintain this height when set. /// public override float Height { get { if (requiresAutoSizedHeight) computeCharacters(); return base.Height; } set { if (explicitHeight == value) return; base.Height = value; explicitHeight = value; invalidate(true, true); } } /// /// Gets or sets the size of this . The will maintain this size when set. /// public override Vector2 Size { get { if (requiresAutoSizedWidth || requiresAutoSizedHeight) computeCharacters(); return base.Size; } set { Width = value.X; Height = value.Y; } } private Vector2 spacing; /// /// Gets or sets the spacing between characters of this . /// public Vector2 Spacing { get => spacing; set { if (spacing == value) return; spacing = value; invalidate(true, true); } } private MarginPadding padding; /// /// Shrinks the space which may be occupied by characters of this by the specified amount on each side. /// public MarginPadding Padding { get => padding; set { if (padding.Equals(value)) return; if (!Validation.IsFinite(value)) throw new ArgumentException($@"{nameof(Padding)} must be finite, but is {value}."); padding = value; invalidate(true, true); } } public override bool IsPresent => base.IsPresent && (AlwaysPresent || !string.IsNullOrEmpty(displayedText)); #region Characters private readonly LayoutValue charactersCache = new LayoutValue(Invalidation.DrawSize | Invalidation.Presence, InvalidationSource.Parent); /// /// Glyph list to be passed to . /// private readonly List charactersBacking = new List(); /// /// The characters in local space. /// private List characters { get { computeCharacters(); return charactersBacking; } } /// /// Compute character textures and positions. /// private void computeCharacters() { if (LoadState >= LoadState.Loaded) ThreadSafety.EnsureUpdateThread(); if (store == null) return; if (charactersCache.IsValid) return; charactersBacking.Clear(); // Todo: Re-enable this assert after autosize is split into two passes. // Debug.Assert(!isComputingCharacters, "Cyclic invocation of computeCharacters()!"); Vector2 textBounds = Vector2.Zero; try { if (string.IsNullOrEmpty(displayedText)) return; TextBuilder textBuilder = getTextBuilder(); textBuilder.Reset(); textBuilder.AddText(displayedText); textBounds = textBuilder.Bounds; } finally { if (requiresAutoSizedWidth) base.Width = textBounds.X + Padding.Right; if (requiresAutoSizedHeight) base.Height = textBounds.Y + Padding.Bottom; base.Width = Math.Min(base.Width, MaxWidth); charactersCache.Validate(); } } private readonly LayoutValue parentScreenSpaceCache = new LayoutValue(Invalidation.DrawSize | Invalidation.Presence | Invalidation.DrawInfo, InvalidationSource.Parent); private readonly LayoutValue localScreenSpaceCache = new LayoutValue(Invalidation.MiscGeometry, InvalidationSource.Self); private readonly List screenSpaceCharactersBacking = new List(); /// /// The characters in screen space. These are ready to be drawn. /// private List screenSpaceCharacters { get { computeScreenSpaceCharacters(); return screenSpaceCharactersBacking; } } private void computeScreenSpaceCharacters() { if (!parentScreenSpaceCache.IsValid) { localScreenSpaceCache.Invalidate(); parentScreenSpaceCache.Validate(); } if (localScreenSpaceCache.IsValid) return; screenSpaceCharactersBacking.Clear(); Vector2 inflationAmount = DrawInfo.MatrixInverse.ExtractScale().Xy; foreach (var character in characters) { screenSpaceCharactersBacking.Add(new ScreenSpaceCharacterPart { DrawQuad = ToScreenSpace(character.DrawRectangle.Inflate(inflationAmount)), InflationPercentage = Vector2.Divide(inflationAmount, character.DrawRectangle.Size), Texture = character.Texture }); } localScreenSpaceCache.Validate(); } private readonly LayoutValue shadowOffsetCache = new LayoutValue(Invalidation.DrawInfo, InvalidationSource.Parent); private Vector2 premultipliedShadowOffset => shadowOffsetCache.IsValid ? shadowOffsetCache.Value : shadowOffsetCache.Value = ToScreenSpace(shadowOffset * Font.Size) - ToScreenSpace(Vector2.Zero); #endregion #region Invalidation private void invalidate(bool characters = false, bool textBuilder = false) { if (characters) charactersCache.Invalidate(); if (textBuilder) InvalidateTextBuilder(); parentScreenSpaceCache.Invalidate(); localScreenSpaceCache.Invalidate(); Invalidate(Invalidation.DrawNode); } #endregion #region DrawNode protected override DrawNode CreateDrawNode() => new SpriteTextDrawNode(this); #endregion /// /// The characters that should be excluded from fixed-width application. Defaults to (".", ",", ":", " ") if null. /// protected virtual char[] FixedWidthExcludeCharacters => null; /// /// The character to use to calculate the fixed width width. Defaults to 'm'. /// protected virtual char FixedWidthReferenceCharacter => 'm'; /// /// The character to fallback to use if a character glyph lookup failed. /// protected virtual char FallbackCharacter => '?'; private readonly LayoutValue textBuilderCache = new LayoutValue(Invalidation.DrawSize, InvalidationSource.Parent); /// /// Invalidates the current , causing a new one to be created next time it's required via . /// protected void InvalidateTextBuilder() => textBuilderCache.Invalidate(); /// /// Creates a to generate the character layout for this . /// /// The where characters should be retrieved from. /// The . protected virtual TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) { var excludeCharacters = FixedWidthExcludeCharacters ?? default_never_fixed_width_characters; float builderMaxWidth = requiresAutoSizedWidth ? MaxWidth : ApplyRelativeAxes(RelativeSizeAxes, new Vector2(Math.Min(MaxWidth, base.Width), base.Height), FillMode).X - Padding.Right; if (AllowMultiline) { return new MultilineTextBuilder(store, Font, builderMaxWidth, UseFullGlyphHeight, new Vector2(Padding.Left, Padding.Top), Spacing, charactersBacking, excludeCharacters, FallbackCharacter, FixedWidthReferenceCharacter); } if (Truncate) { return new TruncatingTextBuilder(store, Font, builderMaxWidth, ellipsisString, UseFullGlyphHeight, new Vector2(Padding.Left, Padding.Top), Spacing, charactersBacking, excludeCharacters, FallbackCharacter, FixedWidthReferenceCharacter); } return new TextBuilder(store, Font, builderMaxWidth, UseFullGlyphHeight, new Vector2(Padding.Left, Padding.Top), Spacing, charactersBacking, excludeCharacters, FallbackCharacter, FixedWidthReferenceCharacter); } private TextBuilder getTextBuilder() { if (!textBuilderCache.IsValid) textBuilderCache.Value = CreateTextBuilder(store); return textBuilderCache.Value; } public override string ToString() => $@"""{displayedText}"" " + base.ToString(); /// /// Gets the base height of the font used by this text. If the font of this text is invalid, 0 is returned. /// public float LineBaseHeight { get { var baseHeight = store.GetBaseHeight(Font.FontName); if (baseHeight.HasValue) return baseHeight.Value * Font.Size; if (string.IsNullOrEmpty(displayedText)) return 0; return store.GetBaseHeight(displayedText[0]).GetValueOrDefault() * Font.Size; } } public IEnumerable FilterTerms => displayedText.Yield(); } }