diff --git a/BeatSaberMarkupLanguage/BeatSaberMarkupLanguage.csproj b/BeatSaberMarkupLanguage/BeatSaberMarkupLanguage.csproj
index 40ed50f6..1baed152 100644
--- a/BeatSaberMarkupLanguage/BeatSaberMarkupLanguage.csproj
+++ b/BeatSaberMarkupLanguage/BeatSaberMarkupLanguage.csproj
@@ -149,6 +149,7 @@
+
@@ -218,6 +219,7 @@
+
@@ -265,6 +267,7 @@
+
diff --git a/BeatSaberMarkupLanguage/Components/ScrollingText.cs b/BeatSaberMarkupLanguage/Components/ScrollingText.cs
new file mode 100644
index 00000000..0c373de5
--- /dev/null
+++ b/BeatSaberMarkupLanguage/Components/ScrollingText.cs
@@ -0,0 +1,413 @@
+using System.Collections;
+using UnityEngine;
+using UnityEngine.UI;
+using TMPro;
+
+namespace BeatSaberMarkupLanguage.Components
+{
+ [RequireComponent(typeof(RectTransform), typeof(RectMask2D))]
+ public class ScrollingText : MonoBehaviour
+ {
+ public RectTransform rectTransform { get; private set; }
+ public TextMeshProUGUI textComponent { get; private set; }
+
+ private string _cachedText = "";
+
+ public ScrollMovementType movementType { get; set; } = ScrollMovementType.ByDuration;
+ private ScrollAnimationType _animationType = ScrollAnimationType.Basic;
+ public ScrollAnimationType animationType
+ {
+ get => _animationType;
+ set
+ {
+ if (_animationType == value)
+ return;
+
+ _animationType = value;
+
+ if (_scrollAnimationCoroutine != null)
+ {
+ StopCoroutine(_scrollAnimationCoroutine);
+ StartAnimation();
+ }
+ }
+ }
+ private float _textWidthRatioThreshold = 1.2f;
+ ///
+ /// The minimum ratio of text width to container width before scrolling occurs.
+ /// Otherwise, if the text is wider than the container, the text will be scaled down to fit the container.
+ ///
+ public float textWidthRatioThreshold
+ {
+ get => _textWidthRatioThreshold;
+ set
+ {
+ if (_textWidthRatioThreshold == value || value < 1f)
+ return;
+
+ _textWidthRatioThreshold = value;
+ }
+ }
+ private WaitForSeconds _pauseDuration = new WaitForSeconds(2f);
+ ///
+ /// The number of seconds to wait before the scrolling animation starts and ends.
+ ///
+ public float pauseDuration
+ {
+ set
+ {
+ if (value > 0f)
+ _pauseDuration = new WaitForSeconds(value);
+ }
+ }
+ private float _scrollDuration = 2f;
+ ///
+ /// The number of seconds it takes to scroll the text.
+ ///
+ public float scrollDuration
+ {
+ get => _scrollDuration;
+ set
+ {
+ if (value > 0f)
+ _scrollDuration = value;
+ }
+ }
+ private float _scrollSpeed = 20f;
+ ///
+ /// The speed at which the text scrolls in units per second.
+ ///
+ public float scrollSpeed
+ {
+ get => _scrollSpeed;
+ set
+ {
+ if (value > 0f)
+ _scrollSpeed = value;
+ }
+ }
+ public bool alwaysScroll { get; set; } = false;
+
+ private float _cachedTextWidth = 0f;
+ private float TextWidth
+ {
+ get
+ {
+ if (_cachedText != textComponent.text)
+ {
+ _cachedTextWidth = textComponent.GetPreferredValues().x;
+ _cachedText = textComponent.text;
+ }
+
+ return _cachedTextWidth;
+ }
+ }
+ private float TextWidthRatio => rectTransform.rect.width != 0f ? TextWidth / rectTransform.rect.width : 0f;
+
+ private IEnumerator _scrollAnimationCoroutine;
+
+ private const float ScalingMinimumSizeRatio = 0.98f;
+ private const float FadeDurationSeconds = 0.6f;
+
+ private void Awake()
+ {
+ rectTransform = GetComponent();
+
+ textComponent = BeatSaberUI.CreateText(rectTransform, "", Vector2.zero);
+ var rt = textComponent.rectTransform;
+ rt.anchorMin = Vector2.zero;
+ rt.anchorMax = Vector2.one;
+ rt.pivot = new Vector2(0.5f, 0.5f);
+ rt.sizeDelta = Vector2.zero;
+
+ // the text object can be hit by the raycast, even when it is masked off
+ // i'm guessing it has something to do with how the text is now curved
+ // in any case, disallow the text to be a raycast target so it doesn't cover other UI elements
+ textComponent.raycastTarget = false;
+
+ // allow animation to restart when text has changed
+ textComponent.RegisterDirtyLayoutCallback(OnTextComponentDirtyLayout);
+ }
+
+ private void OnEnable()
+ {
+ RecalculateElements(true);
+ }
+
+ private void OnDisable()
+ {
+ if (_scrollAnimationCoroutine != null)
+ {
+ StopCoroutine(_scrollAnimationCoroutine);
+ _scrollAnimationCoroutine = null;
+ }
+ }
+
+ private void OnTextComponentDirtyLayout()
+ {
+ if (textComponent.text != _cachedText)
+ StartCoroutine(DelayedRecalculateElements());
+ }
+
+ private void OnRectTransformDimensionsChange()
+ {
+ RecalculateElements(true);
+ }
+
+ private void RecalculateElements(bool force = false)
+ {
+ // do not reset the animation if the text has not changed
+ // _cachedText will be set when accessing TextWidth
+ if (textComponent.text == _cachedText && !force)
+ return;
+
+ if (_scrollAnimationCoroutine != null)
+ {
+ StopCoroutine(_scrollAnimationCoroutine);
+ _scrollAnimationCoroutine = null;
+ }
+
+ var rt = textComponent.rectTransform;
+ rt.anchoredPosition = Vector2.zero;
+ if (TextWidthRatio >= _textWidthRatioThreshold || alwaysScroll)
+ {
+ // resize the text component's RectTransform to take on the width of the text
+ // otherwise, if the text is too long, it will disappear
+ rt.anchorMin = new Vector2(0.5f, 0f);
+ rt.anchorMax = new Vector2(0.5f, 1f);
+ rt.sizeDelta = new Vector2(TextWidth, 0f);
+
+ // position is set in the start of the animation
+ StartAnimation();
+ }
+ else
+ {
+ // if we're changing the text here, this function will fire once again,
+ // but that should be fine
+ if (TextWidthRatio > ScalingMinimumSizeRatio)
+ {
+ string scaleString = ((int)(100 * (ScalingMinimumSizeRatio + 0.001f) / TextWidthRatio)).InvariantToString("N0");
+ textComponent.text = $"" + textComponent.text + "";
+ }
+
+ rt.anchorMin = Vector2.zero;
+ rt.anchorMax = Vector2.one;
+ rt.sizeDelta = Vector2.zero;
+ rt.anchoredPosition = Vector2.zero;
+ }
+ }
+
+ private IEnumerator DelayedRecalculateElements(bool force = false)
+ {
+ yield return null;
+ RecalculateElements(force);
+ }
+
+ private void StartAnimation()
+ {
+ if (animationType == ScrollAnimationType.FadeInOut)
+ _scrollAnimationCoroutine = FadeScrollAnimationCoroutine();
+ else if (animationType == ScrollAnimationType.ForwardAndReverse)
+ _scrollAnimationCoroutine = ForwardReverseScrollAnimationCoroutine();
+ else if (animationType == ScrollAnimationType.Continuous)
+ _scrollAnimationCoroutine = ContinuousScrollAnimationCoroutine();
+ else
+ _scrollAnimationCoroutine = BasicScrollAnimationCoroutine();
+
+ StartCoroutine(_scrollAnimationCoroutine);
+ }
+
+ #region Animation Coroutines
+ private IEnumerator ScrollAnimationCoroutine(Vector2 startPos, Vector2 endPos)
+ {
+ RectTransform rt = textComponent.rectTransform;
+
+ if (movementType == ScrollMovementType.ByDuration)
+ {
+ Vector2 nextPos = startPos;
+ float seconds = 0f;
+
+ if (startPos.x > endPos.x)
+ {
+ // left to right
+ while (nextPos.x > endPos.x)
+ {
+ nextPos.x = Mathf.Lerp(startPos.x, endPos.x, seconds / scrollDuration);
+ rt.anchoredPosition = nextPos;
+ seconds += Time.deltaTime;
+ yield return null;
+ }
+ }
+ else
+ {
+ // right to left
+ while (nextPos.x < endPos.x)
+ {
+ nextPos.x = Mathf.Lerp(startPos.x, endPos.x, seconds / scrollDuration);
+ rt.anchoredPosition = nextPos;
+ seconds += Time.deltaTime;
+ yield return null;
+ }
+ }
+ }
+ else if (movementType == ScrollMovementType.BySpeed)
+ {
+ Vector2 nextPos = startPos;
+ if (startPos.x > endPos.x)
+ {
+ // left to right
+ nextPos.x -= Time.deltaTime * scrollSpeed;
+ while (nextPos.x > endPos.x)
+ {
+ rt.anchoredPosition = nextPos;
+ yield return null;
+ nextPos.x -= Time.deltaTime * scrollSpeed;
+ }
+ }
+ else
+ {
+ // right to left
+ nextPos.x += Time.deltaTime * scrollSpeed;
+ while (nextPos.x < endPos.x)
+ {
+ rt.anchoredPosition = nextPos;
+ yield return null;
+ nextPos.x += Time.deltaTime * scrollSpeed;
+ }
+ }
+ }
+ }
+
+ private IEnumerator BasicScrollAnimationCoroutine()
+ {
+ RectTransform rt = this.textComponent.rectTransform;
+ float halfTextWidth = TextWidth / 2f;
+ float halfWidth = rectTransform.rect.width / 2f;
+ Vector2 startPos = new Vector2(halfTextWidth - halfWidth, 0f);
+ Vector2 endPos = new Vector2(halfWidth - halfTextWidth, 0f);
+
+ while (true)
+ {
+ rt.anchoredPosition = startPos;
+ yield return _pauseDuration;
+
+ IEnumerator anim = ScrollAnimationCoroutine(startPos, endPos);
+ while (anim.MoveNext())
+ yield return anim.Current;
+
+ rt.anchoredPosition = endPos;
+ yield return _pauseDuration;
+ }
+ }
+
+ private IEnumerator FadeScrollAnimationCoroutine()
+ {
+ RectTransform rt = this.textComponent.rectTransform;
+ float halfTextWidth = TextWidth / 2f;
+ float halfWidth = rectTransform.rect.width / 2f;
+ Vector2 startPos = new Vector2(halfTextWidth - halfWidth, 0f);
+ Vector2 endPos = new Vector2(halfWidth - halfTextWidth, 0f);
+
+ while (true)
+ {
+ rt.anchoredPosition = startPos;
+
+ // fade in
+ float seconds = 0f;
+ while (seconds < FadeDurationSeconds)
+ {
+ yield return null;
+
+ Color currentColor = textComponent.color;
+ currentColor.a = Mathf.Lerp(0f, 1f, seconds / FadeDurationSeconds);
+ textComponent.color = currentColor;
+
+ seconds += Time.deltaTime;
+ }
+ yield return _pauseDuration;
+
+ IEnumerator anim = ScrollAnimationCoroutine(startPos, endPos);
+ while (anim.MoveNext())
+ yield return anim.Current;
+
+ rt.anchoredPosition = endPos;
+ yield return _pauseDuration;
+
+ // fade out
+ seconds = 0f;
+ while (seconds < FadeDurationSeconds)
+ {
+ Color currentColor = textComponent.color;
+ currentColor.a = Mathf.Lerp(1f, 0f, seconds / FadeDurationSeconds);
+ textComponent.color = currentColor;
+
+ seconds += Time.deltaTime;
+ yield return null;
+ }
+ }
+ }
+
+ private IEnumerator ForwardReverseScrollAnimationCoroutine()
+ {
+ RectTransform rt = this.textComponent.rectTransform;
+ float halfTextWidth = TextWidth / 2f;
+ float halfWidth = rectTransform.rect.width / 2f;
+ Vector2 startPos = new Vector2(halfTextWidth - halfWidth, 0f);
+ Vector2 endPos = new Vector2(halfWidth - halfTextWidth, 0f);
+
+ while (true)
+ {
+ rt.anchoredPosition = startPos;
+ yield return _pauseDuration;
+
+ IEnumerator anim = ScrollAnimationCoroutine(startPos, endPos);
+ while (anim.MoveNext())
+ yield return anim.Current;
+
+ rt.anchoredPosition = endPos;
+ yield return _pauseDuration;
+
+ anim = ScrollAnimationCoroutine(endPos, startPos);
+ while (anim.MoveNext())
+ yield return anim.Current;
+ }
+ }
+
+ private IEnumerator ContinuousScrollAnimationCoroutine()
+ {
+ RectTransform rt = this.textComponent.rectTransform;
+ float halfTextWidth = TextWidth / 2f;
+ float halfWidth = rectTransform.rect.width / 2f;
+ Vector2 startPos = new Vector2(halfTextWidth + halfWidth, 0f);
+ Vector2 endPos = new Vector2(-halfTextWidth - halfWidth, 0f);
+
+ while (true)
+ {
+ rt.anchoredPosition = startPos;
+ yield return null;
+
+ IEnumerator anim = ScrollAnimationCoroutine(startPos, endPos);
+ while (anim.MoveNext())
+ yield return anim.Current;
+
+ rt.anchoredPosition = endPos;
+ yield return null;
+ }
+ }
+ #endregion
+
+ public enum ScrollMovementType
+ {
+ ByDuration,
+ BySpeed
+ }
+
+ public enum ScrollAnimationType
+ {
+ Basic,
+ FadeInOut,
+ ForwardAndReverse,
+ Continuous
+ }
+ }
+}
diff --git a/BeatSaberMarkupLanguage/Parse.cs b/BeatSaberMarkupLanguage/Parse.cs
index 0fa8ffec..1b6e929e 100644
--- a/BeatSaberMarkupLanguage/Parse.cs
+++ b/BeatSaberMarkupLanguage/Parse.cs
@@ -53,5 +53,10 @@ public static string InvariantToString(this object obj)
else
return obj.ToString();
}
+
+ public static string InvariantToString(this IFormattable obj, string format)
+ {
+ return obj.ToString(format, CultureInfo.InvariantCulture);
+ }
}
}
diff --git a/BeatSaberMarkupLanguage/Tags/ScrollingTextTag.cs b/BeatSaberMarkupLanguage/Tags/ScrollingTextTag.cs
new file mode 100644
index 00000000..ed0db776
--- /dev/null
+++ b/BeatSaberMarkupLanguage/Tags/ScrollingTextTag.cs
@@ -0,0 +1,34 @@
+using UnityEngine;
+using UnityEngine.UI;
+using BeatSaberMarkupLanguage.Components;
+
+namespace BeatSaberMarkupLanguage.Tags
+{
+ public class ScrollingTextTag : BSMLTag
+ {
+ public override string[] Aliases => new[] { "scrolling-text" };
+
+ public override GameObject CreateObject(Transform parent)
+ {
+ GameObject gameObject = new GameObject("BSMLScrollingText");
+ gameObject.transform.SetParent(parent, false);
+
+ ScrollingText scrollingText = gameObject.AddComponent();
+ scrollingText.rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
+ scrollingText.rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
+ scrollingText.rectTransform.sizeDelta = new Vector2(90f, 6f);
+ scrollingText.textComponent.text = "Default Text";
+ scrollingText.textComponent.fontSize = 3f;
+ scrollingText.textComponent.color = Color.white;
+
+ LayoutElement layoutElement = gameObject.AddComponent();
+ layoutElement.preferredHeight = 6f;
+ layoutElement.preferredWidth = 90f;
+
+ ExternalComponents externalComponents = gameObject.AddComponent();
+ externalComponents.components.Add(scrollingText.textComponent);
+
+ return gameObject;
+ }
+ }
+}
diff --git a/BeatSaberMarkupLanguage/TypeHandlers/ScrollingTextHandler.cs b/BeatSaberMarkupLanguage/TypeHandlers/ScrollingTextHandler.cs
new file mode 100644
index 00000000..3083fbe5
--- /dev/null
+++ b/BeatSaberMarkupLanguage/TypeHandlers/ScrollingTextHandler.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using BeatSaberMarkupLanguage.Components;
+
+namespace BeatSaberMarkupLanguage.TypeHandlers
+{
+ [ComponentHandler(typeof(ScrollingText))]
+ public class ScrollingTextHandler : TypeHandler
+ {
+ public override Dictionary Props => new Dictionary()
+ {
+ { "movementType", new[]{ "movement-type" } },
+ { "animationType", new[]{ "animation-type" } },
+ { "textWidthRatioThreshold", new[]{ "text-width-ratio-threshold" } },
+ { "pauseDuration", new[]{ "pause-duration" } },
+ { "scrollDuration", new[]{ "scroll-duration" } },
+ { "scrollSpeed", new[]{ "scroll-speed" } },
+ { "alwaysScroll", new[]{ "always-scroll" } },
+ };
+
+ public override Dictionary> Setters => new Dictionary>()
+ {
+ { "movementType", new Action((scrollingText, value) => scrollingText.movementType = (ScrollingText.ScrollMovementType)Enum.Parse(typeof(ScrollingText.ScrollMovementType), value)) },
+ { "animationType", new Action((scrollingText, value) => scrollingText.animationType = (ScrollingText.ScrollAnimationType)Enum.Parse(typeof(ScrollingText.ScrollAnimationType), value)) },
+ { "textWidthRatioThreshold", new Action((scrollingText, value) => scrollingText.textWidthRatioThreshold = Parse.Float(value)) },
+ { "pauseDuration", new Action((scrollingText, value) => scrollingText.pauseDuration = Parse.Float(value)) },
+ { "scrollDuration", new Action((scrollingText, value) => scrollingText.scrollDuration = Parse.Float(value)) },
+ { "scrollSpeed", new Action((scrollingText, value) => scrollingText.scrollSpeed = Parse.Float(value)) },
+ { "alwaysScroll", new Action((scrollingText, value) => scrollingText.alwaysScroll = Parse.Bool(value)) },
+ };
+ }
+}
diff --git a/BeatSaberMarkupLanguage/ViewControllers/TestViewController.cs b/BeatSaberMarkupLanguage/ViewControllers/TestViewController.cs
index 055c205b..30ad898a 100644
--- a/BeatSaberMarkupLanguage/ViewControllers/TestViewController.cs
+++ b/BeatSaberMarkupLanguage/ViewControllers/TestViewController.cs
@@ -77,6 +77,62 @@ public List