//#define VERBOSE using Nova; using System; using System.Collections.Generic; using TMPro; using UnityEngine; namespace NovaSamples.UIControls { public class TextField : MonoBehaviour { /// /// Fires whenever the cursor position changes. /// public event Action OnCursorPositionChanged; /// /// Fires whenever the highlighted text changes /// public event Action OnTextSelectionChanged; /// /// Fires whenever the text has changed /// public event Action OnTextChanged; [SerializeField] [Tooltip("The TextBlock used to render the text")] private TextBlock textBlock = null; [SerializeField] [Tooltip("Optional placeholder text to display when the input text is empty")] private TextBlock placeHolderText = null; [Header("Cursor")] [SerializeField] [Tooltip("The prefab instantiated to represent the current input position")] private UIBlock2D cursorPrefab = null; [SerializeField] [Tooltip("The frequency with which the cursor will be enabled/disabled")] private float cursorBlinkRate = .4f; [Header("Scroller")] [SerializeField] [Tooltip("The Scroller responsible for automatically scrolling the text when the cursor moves out of view.\nCan be null if scrolling text is undesired.")] private Scroller scroller = null; [Header("Hightlight")] [SerializeField] [Tooltip("The prefab instantiated to represent selected/highlighted text")] private UIBlock2D highlightPrefab = null; /// /// The animation handle for blinking the cursor. /// private AnimationHandle cursorBlinkHandle = default; /// /// The current string position of the cursor. /// private TextPosition cursorPosition = default; /// /// The starting point of the current selection. /// private TextPosition selectionStartPosition = default; /// /// The visual for the cursor, instantiated from . /// private UIBlock2D cursorVisual = null; /// /// The highlights that are currently disabled and can be reused /// private List highlightPool = new List(); /// /// The currently active highlights /// private List activeHighlights = new List(); private string _text = null; /// /// The 's current text, which may not be the same as the string /// that is visible (i.e. TextBlock.Text) due to /// needing to add a trailing empty space to ensure correct cursor position /// in certain sitations. /// /// /// This should not be used internally (i.e. by itself), /// as does not correspond to a position in /// this string, but in TextBlock.Text. In other words, /// should be considered the final string value, and can be fed into other components as /// the "real" value, while TextBlock.Text is what the /// user is interacting with and sees. /// /// /// public string Text { get { if (_text == null) { // The text hasn't been initialized yet, so pull // it from the text block, but convert it from a display string _text = FromDisplayText(TextBlock.Text); } return _text; } set { // Make sure not null string newText = value ?? string.Empty; if (newText == _text) { // Text already set to this, nothing to do return; } // Save off the actual string value _text = newText; // Convert the string to a display string by inserting any // empty spaces if necessary string displayText = ToDisplayText(newText); if (placeHolderText != null) { // If we have a placeholder, enable/disable it based on whether or not the // display string is empty placeHolderText.Visible = string.IsNullOrEmpty(displayText); } // Update the TextBlock and force a text mesh update so that any cursor/highlight // adjustments are valid immediately TextBlock.Text = displayText; TextBlock.TMP.ForceMeshUpdate(); } } /// /// The TextBlock which is used to visualize the value for this input field. /// public TextBlock TextBlock { get { if (textBlock == null) { textBlock = GetComponent(); } return textBlock; } } /// /// The current cursor position.
///
/// /// NOTE: This is NOT a position in , but in the display string /// (i.e., TextBlock.Text). See /// for more details. /// public TextPosition CursorPosition { get { if (!EnsureInitialized()) { throw new Exception("Failed to get CursorPosition"); } return cursorPosition; } } /// /// Is there any text highlight/selected? /// public bool HasTextSelection => EnsureInitialized() && cursorPosition.IsValid && selectionStartPosition.IsValid && !cursorPosition.Equals(selectionStartPosition); /// /// The current position (in space) of the cursor. /// public Vector2 CursorVisualPosition => EnsureInitialized() ? GetCenteredPosition(cursorVisual) : Vector2.zero; /// /// The height of the cursor (in space). /// public Vector2 CursorSize => cursorVisual.CalculatedSize.XY.Value; private bool HasScrollbarVisual => scroller != null && scroller.ScrollbarVisual != null; #region Public API /// /// Sets the cursor position and, optionally, the selection/highlight start location. /// /// /// This checks to make sure is valid, so it can be called with /// invalid cursor positions. /// public void SetCursorPosition(TextPosition newCursorPosition, TextPosition newSelectionStart = default) { if (!EnsureInitialized()) { // Don't do anything if not initialized. return; } if (!newCursorPosition.IsValid) { if (newCursorPosition.TextMeshIsEmpty) { // If the string is empty, move the cursor to the // empty string position HandleEmptyString(); } // Not a valid position return; } if (newSelectionStart.TextBlock == null || !newSelectionStart.IsValid) { // Not initialized or invalid, so just use cursor position (no selection) newSelectionStart = newCursorPosition; } // Set the selection start and then move the cursor with highlight set to true // so that everything inbetween gets selected selectionStartPosition = newSelectionStart; MoveCursor(newCursorPosition, true); } /// /// Moves the cursor to . If is true, /// the highlight will be maintained (any text between the new cursor position and the old position /// will be highlighted). If is false, the selection will be cleared. /// /// /// This checks to make sure is valid, so it can be called with /// invalid cursor positions. /// public void MoveCursor(TextPosition newPos, bool highlight) { if (!EnsureInitialized()) { // Don't do anything if not initialized. return; } textBlock.CalculateLayout(); if (!newPos.IsValid) { if (newPos.TextMeshIsEmpty) { // If the string is empty, move the cursor to the // empty string position HandleEmptyString(); } // Early return if not valid position return; } // Set the cursor position cursorPosition = newPos; #if VERBOSE Debug.Log($"Setting cursor to: {cursorPosition}"); #endif // Ensure the cursor height is correct cursorVisual.Size.Y.Value = cursorPosition.CharHeight; // Get the new cursor position // NOTE: We add half of the cursor width since cursorPosition.XPos describes the // location of the left edge of the cursor Vector2 cursorSize = cursorVisual.Size.XY.Value; // Reset the cursor animation to guarantee that it's initially solid ResetCursorAnimation(); MatchTextAlignment(cursorVisual); // The same as position, but adding in half of the cursor width since // cursorPosition.XPos describes the location of the left edge of the cursor Vector2 centeredPosition = new Vector2(cursorPosition.XPos + .5f * cursorSize.x, cursorPosition.YPos); Vector2 position = ConvertCenteredPositionToAlignedPosition(cursorVisual, centeredPosition, cursorVisual.Size.XY.Value); // Update cursor visual position cursorVisual.Position.XY.Value = position; // Scroll the text to ensure the cursor is in view ScrollTextToCursorPosition(); // Notify subscribers of cursor position change OnCursorPositionChanged?.Invoke(); if (!highlight) { // If we don't want to highlight, just set the selection start to // the new cursor position selectionStartPosition = cursorPosition; } UpdateHighlightVisuals(); } /// /// Gets the highlighted string if there is one, otherwise returns the empty string. /// public string GetSelectedString() { if (!HasTextSelection) { return string.Empty; } // Get the left and right boundaries of the selection GetSelectionLeftRight(out TextPosition left, out TextPosition right); // Get the string slice int start = left.StringIndex; int count = right.StringIndex - start; // Get the substring string subStr = TextBlock.TMP.text.Substring(start, count); // Since this is a display string, we need to convert it return FromDisplayText(subStr); } /// /// Get the left and right positions of the selection, since the cursor /// position may actually come before the selection start (for example, if /// the user drags their mouse to the left). /// public void GetSelectionLeftRight(out TextPosition left, out TextPosition right) { left = cursorPosition < selectionStartPosition ? cursorPosition : selectionStartPosition; right = cursorPosition > selectionStartPosition ? cursorPosition : selectionStartPosition; } /// /// Hides both the cursor and highlights (if any). /// public void HideAllVisuals() { HideCursorVisual(); ClearTextSelection(); } /// /// Hides the cursor visual. /// public void HideCursorVisual() { #if VERBOSE Debug.Log("Hiding cursor visual"); #endif // Cancel the animation cursorBlinkHandle.Cancel(); if (cursorVisual != null) { // Hide the cursor cursorVisual.BodyEnabled = false; } } /// /// Clears the current selection /// public void ClearTextSelection() { if (!EnsureInitialized()) { // Don't do anything if not initialized. return; } selectionStartPosition = cursorPosition; UpdateHighlightVisuals(); } /// /// Converts a display string position to raw string index. /// public int StringIndexFromTextPosition(TextPosition textPosition) { if (!EnsureInitialized()) { return -1; } string displayString = textPosition.TextBlock.Text; // Go through the display string and bump the string index for every // non empty character int displayStringStart = textPosition.StringIndex; int stringPosition = 0; for (int i = 0; i < displayStringStart; ++i) { if (!displayString[i].IsEmptySpace()) { stringPosition += 1; } } return stringPosition; } /// /// Converts a raw string index into to a /// in the display string. /// public TextPosition TextPositionFromStringIndex(int stringPosition) { if (!EnsureInitialized()) { throw new Exception("Tried to get text position for TextField when not initialized"); } if (stringPosition > Text.Length) { throw new Exception($"Tried to get text position {stringPosition} when string length was {Text.Length}"); } if (stringPosition == Text.Length) { // It's the end of the string return cursorPosition.MoveToEnd(); } // Go through the display string until we've reached stringPosition // non-empty characters string displayString = TextBlock.Text; int displayIndex = 0; while (stringPosition > 0 && displayIndex <= displayString.Length) { if (!displayString[displayIndex].IsEmptySpace()) { stringPosition -= 1; } displayIndex++; } return TextPosition.Create(TextBlock, displayIndex); } #region Text Modification /// /// Deletes one character to the left of , if /// possible. /// /// /// This is valid to call at any point, even if the operation has no effect. /// public void DeleteLeft() { if (!EnsureInitialized()) { // Don't do anything if not initialized. return; } if (HasTextSelection) { // If there is any text selected, just delete that DeleteSelection(); return; } Delete(cursorPosition.MoveLeft(), cursorPosition); } /// /// Deletes one character to the right of , if possible. /// /// /// This is valid to call at any point, even if the operation has no effect. /// public void DeleteRight() { if (!EnsureInitialized()) { // Don't do anything if not initialized. return; } if (HasTextSelection) { // If there is any text selected, just delete that DeleteSelection(); return; } Delete(cursorPosition, cursorPosition.MoveRight()); } /// /// Deletes the currently highlighted/selected text. /// /// /// This is valid to call at any point, even if the operation has no effect. /// public void DeleteSelection() { if (!EnsureInitialized()) { // Don't do anything if not initialized. return; } GetSelectionLeftRight(out TextPosition left, out TextPosition right); Delete(left, right); } /// /// Inserts into the string at . /// If there is currently any text selected (i.e. is true), the /// selected text is also deleted. /// /// /// This is valid to call at any point, even if the operation has no effect. /// public void Insert(char c) { Insert(c.ToString()); } /// /// Inserts into the string at . /// If there is currently any text selected (i.e. is true), the /// selected text is also deleted. /// /// /// This is valid to call at any point, even if the operation has no effect. /// public void Insert(string toInsert) { if (!EnsureInitialized()) { // Don't do anything if not initialized. return; } // First delete the current selection if there is one DeleteSelection(); if (cursorPosition.TextMeshIsEmpty) { // If the text is empty, just set the text to the inserted value Text = toInsert; MoveCursor(cursorPosition.MoveToEnd(), false); } else { if (!cursorPosition.IsValid) { return; } // Get the new text string newText = TextBlock.Text.Insert(cursorPosition.StringIndex, toInsert); // Cache the current cursor position TextPosition newCursorPosition = cursorPosition; // Update the text Text = FromDisplayText(newText); // Move the cursor by the length of the inserted text newCursorPosition = newCursorPosition.MoveRight(toInsert.Length); // Set the new cursor position MoveCursor(newCursorPosition, false); } } #endregion #endregion [NonSerialized] private bool initialized = false; /// /// Returns true if already initialized or was able to successfully initialize, /// otherwise returns false. /// private bool EnsureInitialized() { if (initialized) { // Already initialized return true; } if (!isActiveAndEnabled) { // Don't do anything if disabled return false; } if (textBlock == null) { Debug.LogError("No TextBlock assigned to TextField", this); return false; } if (TextBlock.TMP.textInfo == null) { // TMP hasn't initialized yet return false; } if (cursorVisual == null) { // Instantiate the cursor visual if we haven't already if (cursorPrefab == null) { Debug.LogError("No cursor visual assigned on TextField", this); return false; } cursorVisual = Instantiate(cursorPrefab, textBlock.transform, false); cursorVisual.BodyEnabled = false; } #if !UNITY_EDITOR && (UNITY_IOS || UNITY_ANDROID) // On PC, we want drag scrolling to be disabled since that is how a user // highlights text. However, on mobile since the virtual keyboard has its own text field, // we want to enable drag scrolling if (scroller != null) { scroller.DragScrolling = true; } #endif if (HasScrollbarVisual) { scroller.UIBlock.AddGestureHandler(HandleScroll); } TMPro_EventManager.TEXT_CHANGED_EVENT.Add(HandleTextChanged); // Default the cursor and highlight to the beginning. cursorPosition = TextPosition.Create(TextBlock, 0); selectionStartPosition = cursorPosition; // Successfully initialized! initialized = true; return true; } /// /// Handle scroll so we can enable/disable scrollbar. /// /// private void HandleScroll(Gesture.OnScroll evt) => UpdateScrollbarState(); private void OnDisable() { if (!initialized) { return; } HideAllVisuals(); TMPro_EventManager.TEXT_CHANGED_EVENT.Remove(HandleTextChanged); if (scroller != null) { scroller.UIBlock.RemoveGestureHandler(HandleScroll); } initialized = false; } /// /// Refreshes the scrollbar enable/disable state when scrolled or when the text is updated. /// private void UpdateScrollbarState() { if (!HasScrollbarVisual) { return; } if (textBlock.CalculatedSize.Y.Value > scroller.UIBlock.CalculatedSize.Y.Value) { scroller.ScrollbarVisual.gameObject.SetActive(true); } else { scroller.ScrollbarVisual.gameObject.SetActive(false); } } /// /// When the text mesh changes (e.g., due to the string itself changing, or the font size, alignment, etc) /// this ensures that the cursor and highlight visuals are in the correct locations. /// /// private void HandleTextChanged(UnityEngine.Object obj) { if (obj != TextBlock.TMP) { // Different TMP object, do nothing return; } if (cursorPosition.TextMeshIsEmpty) { // Empty string HandleEmptyString(); } else if (cursorPosition.IsValid) { // Since the mesh may have changed, we set the cursor position to ensure // the cursor visual is in the correct spot MoveCursor(cursorPosition, true); } else { // Text updated and the cursor position is no longer valid, // which means the text where the cursor was positioned has been deleted, // so just move it to the end of the string. MoveCursor(CursorPosition.MoveToEnd(), false); } UpdateScrollbarState(); OnTextChanged?.Invoke(); } /// /// Deletes the text inbetween the two provided positions /// private void Delete(TextPosition left, TextPosition right) { if (!EnsureInitialized()) { // Don't do anything if not initialized. return; } if (left.Equals(right)) { // Nothing to delete return; } // Get the start location and count int start = left.StringIndex; int count = right.StringIndex - start; // Get the new text with the character removed string newText = TextBlock.Text.Remove(start, count); // Since we are using the string directly from the text block, // we need to convert it from a display string newText = FromDisplayText(newText); // Update the text Text = newText; // Set the new cursor position MoveCursor(left, false); } /// /// When the string is empty, there is no mesh on which we can base the /// positioning of the cursor, so in order to get the cursor position to be correct /// we must manually adjust its alignment based on the alignment of the TMP component. /// private void HandleEmptyString() { #if VERBOSE Debug.Log($"Setting cursor empty string pos from: {cursorPosition}"); #endif // Move the cursor to the "front" of the empty string cursorPosition = TextPosition.Create(TextBlock, 0); // Set the horizontal and vertical alignment of the cursor based // on the text alignment MatchTextAlignment(cursorVisual); // Reset the cursor animation to ensure it is initially solid ResetCursorAnimation(); // Set the position to VisualOffset, as this will be (0, 0) if the text is // not being hugged, otherwise it will be whatever offset is required to to // get to (0, 0). cursorVisual.Position.XY.Value = TextBlock.VisualOffset; // Scroll the text to ensure the cursor is in view ScrollTextToCursorPosition(); OnCursorPositionChanged?.Invoke(); // Hide highlights if there are any ClearTextSelection(); } /// /// Returns the centered position of the UIBlock, adjusting for alignment as needed /// /// /// private Vector2 GetCenteredPosition(UIBlock block) { Vector2 parentSize = block.Parent.CalculatedSize.XY.Value; Vector2 halfParentSize = .5f * parentSize; Vector2 blockSize = block.CalculatedSize.XY.Value; Vector2 halfBlockSize = .5f * blockSize; Vector2 position = cursorVisual.Position.XY.Value; Vector2 centeredPosition = default; switch (block.Alignment.X) { case HorizontalAlignment.Center: centeredPosition.x = position.x; break; case HorizontalAlignment.Left: centeredPosition.x = position.x - halfParentSize.x + halfBlockSize.x; break; case HorizontalAlignment.Right: centeredPosition.x = halfParentSize.x - position.x - halfBlockSize.x; break; } switch (block.Alignment.Y) { case VerticalAlignment.Center: centeredPosition.y = position.y; break; case VerticalAlignment.Top: //position.y = halfParentSize.y - centeredPosition.y - .5f * blockSize.y; centeredPosition.y = halfParentSize.y - position.y - halfBlockSize.y; break; case VerticalAlignment.Bottom: //position.y = halfParentSize.y + centeredPosition.y - .5f * blockSize.y; centeredPosition.y = position.y - halfParentSize.y + halfBlockSize.y; break; } return centeredPosition; } /// /// Converts the provided target center position of the UIBlock to the /// matching position based on the alignment of the block /// /// /// /// /// private Vector2 ConvertCenteredPositionToAlignedPosition(UIBlock block, Vector2 centeredPosition, Vector2 blockSize) { Vector2 parentSize = block.Parent.CalculatedSize.XY.Value; Vector2 halfParentSize = .5f * parentSize; Vector2 position = default; switch (block.Alignment.X) { case HorizontalAlignment.Center: position.x = centeredPosition.x; break; case HorizontalAlignment.Left: position.x = centeredPosition.x + halfParentSize.x - .5f * blockSize.x; break; case HorizontalAlignment.Right: position.x = halfParentSize.x - centeredPosition.x - .5f * blockSize.x; break; } switch (block.Alignment.Y) { case VerticalAlignment.Center: // Same as position position.y = centeredPosition.y; break; case VerticalAlignment.Top: position.y = halfParentSize.y - centeredPosition.y - .5f * blockSize.y; break; case VerticalAlignment.Bottom: position.y = halfParentSize.y + centeredPosition.y - .5f * blockSize.y; break; } return position; } /// /// Sets the alignment of the provided block to match the text alignment /// private void MatchTextAlignment(UIBlock block) { switch (TextBlock.TMP.horizontalAlignment) { case HorizontalAlignmentOptions.Left: block.Alignment.X = HorizontalAlignment.Left; break; case HorizontalAlignmentOptions.Right: block.Alignment.X = HorizontalAlignment.Right; break; default: block.Alignment.X = HorizontalAlignment.Center; break; } switch (TextBlock.TMP.verticalAlignment) { case VerticalAlignmentOptions.Top: block.Alignment.Y = VerticalAlignment.Top; break; case VerticalAlignmentOptions.Bottom: block.Alignment.Y = VerticalAlignment.Bottom; break; default: block.Alignment.Y = VerticalAlignment.Center; break; } } /// /// Hide all active highlights and return them to the pool. /// private void ReturnAllHighlightsToPool() { for (int i = 0; i < activeHighlights.Count; i++) { activeHighlights[i].BodyEnabled = false; highlightPool.Add(activeHighlights[i]); } activeHighlights.Clear(); } /// /// Returns a new highlight visual, either retrieving one from the pool or /// allocating a new one. /// private UIBlock2D GetFreeHighlightVisual() { if (highlightPool.Count > 0) { // Pop from pool UIBlock2D pooled = highlightPool[highlightPool.Count - 1]; highlightPool.RemoveAt(highlightPool.Count - 1); pooled.BodyEnabled = true; return pooled; } else { // Need to create a new highlight return Instantiate(highlightPrefab, textBlock.transform, false); } } /// /// Updates the highlight visuals based on the new /// and . /// private void UpdateHighlightVisuals() { if (!EnsureInitialized()) { // Don't do anything if not initialized. return; } // Free all active highlights ReturnAllHighlightsToPool(); if (!selectionStartPosition.IsValid) { selectionStartPosition = cursorPosition; } #if VERBOSE Debug.Log($"Updating selection bounds to: {selectionStartPosition} => {cursorPosition}"); #endif // If the selection is not empty, add the highlight visuals if (!cursorPosition.Equals(selectionStartPosition)) { // Get the left and right bounds of the selection GetSelectionLeftRight(out TextPosition left, out TextPosition right); if (left.LineNumber == right.LineNumber) { // Selection is only on a single line, so add one highlight // that encapsulates the entire selection AddHighlightVisual(left, right); } else { // Multi line highlight // Highlight the part on the first line AddHighlightVisual(left, left.MoveToEndOfLine()); // Highlight all intermediate lines for (int i = left.LineNumber + 1; i < right.LineNumber; ++i) { TextPosition lineStart = TextPosition.CreateAtBeginningOfLine(TextBlock, i); AddHighlightVisual(lineStart, lineStart.MoveToEndOfLine()); } // Highlight the part on the last line AddHighlightVisual(right.MoveToStartOfLine(), right); } } // Notify subscribers OnTextSelectionChanged?.Invoke(); } /// /// Adds a highlight visual for the provided bounds /// private void AddHighlightVisual(TextPosition left, TextPosition right) { UIBlock2D highlight = GetFreeHighlightVisual(); MatchTextAlignment(highlight); Vector2 size = default; Vector2 centeredPosition = default; // Use the height of the line size.y = left.LineHeight; // Y position is the center of the line centeredPosition.y = left.LineYCenter; // X position is the center point between the left and right edges // of the selection centeredPosition.x = 0.5f * (left.XPos + right.XPos); // Width just goes from left to right edge size.x = Mathf.Abs(right.XPos - left.XPos); if (size.x == 0f) { // If selection doesn't contain any visible characters (which could // happen if the selection only contains the newline character), // we want to at least show something, so we set the width to be // a quarter of the height, which is arbitrary but looks reasonable. size.x = .25f * size.y; // And adjust the xposition based on the new width centeredPosition.x += .5f * size.x; } Vector2 position = ConvertCenteredPositionToAlignedPosition(highlight, centeredPosition, size); // Set the size and position of the visual highlight.Position.XY.Value = position; highlight.Size.XY.Value = size; // Track the visual activeHighlights.Add(highlight); } /// /// Whenever the cursor moves, we want it to be solid initially. This resets the /// animation to guarantee that. /// private void ResetCursorAnimation() { // Stop the current animation if it's running HideCursorVisual(); // Ensure enabled cursorVisual.BodyEnabled = true; // Kickoff the blink animation cursorBlinkHandle = new CursorBlinkAnimation(cursorVisual).Loop(cursorBlinkRate); } /// /// Try to scroll the text as needed each time the cursor position changes. /// private void ScrollTextToCursorPosition() { if (scroller == null) { // Assuming scrolling is undesired in // this case, so nothing to do here. return; } // Ensure the TextBlock's layout is up to date textBlock.CalculateLayout(); if (CursorPosition.TextMeshIsEmpty) { // Ensure it's not scrolling scroller.CancelScroll(); // Calculate the scroller's UIBlock layout properties // Because its direct child bounds has changed scroller.UIBlock.CalculateLayout(); // If the string is empty scroll to a 0 offset AutoLayout autoLayout = scroller.UIBlock.AutoLayout; // alignmentDirection is the direction in transform space // a positive AutoLayout.Offset will shift the AutoLayout content // // For the X axis, a positive Offset when Left and Center // aligned will shift Right, while Right aligned will shift Left. // // For the Y axis, a positive Offset when Bottom and Center // aligned will shift Up, while Top aligned will shift Down. float alignmentDirection = autoLayout.Alignment == 1 ? -1 : 1; scroller.Scroll(-alignmentDirection * autoLayout.Offset); return; } // The scroller's scrolling axis Axis scrollAxis = scroller.UIBlock.AutoLayout.Axis; if (scrollAxis == Axis.None || scrollAxis == Axis.Z) { // Only handle scrolling on X and Y return; } // Get the cursor position in world space, so we // can convert it into scroller space. Vector3 cursorWorldPosition = textBlock.transform.TransformPoint(CursorVisualPosition); // Get the cursor position in scroller space Vector2 scrollSpacePosition = scroller.transform.InverseTransformPoint(cursorWorldPosition); // The extents of the scrollable region Vector2 halfScrollerSize = 0.5f * scroller.UIBlock.CalculatedSize.Value; // The extents of the cursor Vector2 cursorHalfSize = 0.5f * CursorSize; // Convert the axis into an index // so we can do the calculations // in an axis-agnostic manner int scrollAxisIndex = scrollAxis.Index(); // Get the amount that the cursor is outside bounds of the scroller on either side of the scrollable axis // This is the amount beyond the bounds in the positive direction. For the X axis, // that would be to past the Right edge, and past the Top edge for the Y axis. float afterBounds = (scrollSpacePosition[scrollAxisIndex] + cursorHalfSize[scrollAxisIndex]) - halfScrollerSize[scrollAxisIndex]; // This is the amount beyond the bounds in the negative direction. For the X axis, // that would be to past the Left edge, and past the Bottom edge for the Y axis. float beforeBounds = (scrollSpacePosition[scrollAxisIndex] - cursorHalfSize[scrollAxisIndex]) + halfScrollerSize[scrollAxisIndex]; float scrollAmount = 0; if (textBlock.CalculatedSize[scrollAxisIndex].Value > scroller.UIBlock.CalculatedSize[scrollAxisIndex].Value) { // The size of the text is larger than the scroller, so we are in a "scrollable" state if (afterBounds > 0) { // Negate because we scroll in the direction // opposite to edge the cursor surpasses, // bringing the cursor into view. scrollAmount = -afterBounds; } else if (beforeBounds < 0 || // If on the last line, we don't just want to keep the cursor in bounds but want to ensure // that we haven't scrolled past the end (which can happen if the user deletes the last line) (scrollAxis == Axis.Y && cursorPosition.LineNumber == cursorPosition.TextInfo.lineCount - 1)) { // Negate because we scroll in the direction // opposite to edge the cursor surpasses, // bringing the cursor into view. scrollAmount = -beforeBounds; } } else { // alignmentDirection is the direction in transform space // a positive AutoLayout.Offset will shift the AutoLayout content // // For the X axis, a positive Offset when Left and Center // aligned will shift Right, while Right aligned will shift Left. // // For the Y axis, a positive Offset when Bottom and Center // aligned will shift Up, while Top aligned will shift Down. float alignmentDirection = scroller.UIBlock.AutoLayout.Alignment == 1 ? -1 : 1; // Scroll by the negative directed offset, so we move the scrolled // position to zero if we're not in a scrolling state scrollAmount = -alignmentDirection * scroller.UIBlock.AutoLayout.Offset; } scroller.CancelScroll(); // Calculate the scroller's UIBlock layout properties // Because its direct child bounds has changed scroller.UIBlock.CalculateLayout(); // The cursor is outside the bounds of the scroller, so scroll enough to bring it into view scroller.Scroll(scrollAmount); } #region Display Text Conversions /// /// In order to get correct cursor positioning, we need to add a zero-width space /// at the end of the string if it ends with a newline. /// public static string ToDisplayText(string s) { if (string.IsNullOrEmpty(s)) { return string.Empty; } if (s[s.Length - 1].IsNewline()) { return $"{s}{CharExtensions.EmptyWidthSpace}"; } else { return s; } } /// /// Removes trailing empty space from . /// public static string FromDisplayText(string s) { if (string.IsNullOrEmpty(s)) { return string.Empty; } if (s[s.Length - 1].IsEmptySpace()) { return s.Substring(0, s.Length - 1); } else { return s; } } #endregion /// /// Blink animation for the cursor. Just toggles the cursor on and off. /// private struct CursorBlinkAnimation : IAnimation { public UIBlock2D Target; public void Update(float percentDone) { if (percentDone == 1f) { Target.BodyEnabled = !Target.BodyEnabled; } } public CursorBlinkAnimation(UIBlock2D target) { Target = target; } } } }