// // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. // using Microsoft.MixedReality.Toolkit.Utilities; using System.Collections.Generic; using TMPro; using UnityEngine; namespace Microsoft.MixedReality.Toolkit.UI { /// /// Class for Tooltip object /// Creates a floating tooltip that is attached to an object and moves to stay in view as object rotates with respect to the view. /// [ExecuteAlways] [HelpURL("https://docs.microsoft.com/windows/mixed-reality/mrtk-unity/features/ux-building-blocks/tooltip")] [AddComponentMenu("Scripts/MRTK/SDK/ToolTip")] public class ToolTip : MonoBehaviour { [SerializeField] [Tooltip("Show the opaque background of tooltip.")] private bool showBackground = true; /// /// Show the opaque background of tooltip. /// public bool ShowBackground { get { return showBackground; } set { showBackground = value; } } [SerializeField] private bool showHighlight = false; /// /// Shows white trim around edge of tooltip. /// public bool ShowHighlight { get { return showHighlight; } set { showHighlight = value; } } [SerializeField] [Tooltip("Show the connecting stem between the tooltip and its parent GameObject.")] private bool showConnector = true; /// /// Show the connecting stem between the tooltip and its parent GameObject. /// public bool ShowConnector { get { return showConnector; } set { showConnector = value; } } [SerializeField] [Tooltip("Display the state of the tooltip.")] private DisplayMode tipState = DisplayMode.On; /// /// The display the state of the tooltip. /// public DisplayMode TipState { get { return tipState; } set { tipState = value; } } [SerializeField] [Tooltip("Display the state of a group of tooltips.")] private DisplayMode groupTipState; /// /// Display the state of a group of tooltips. /// public DisplayMode GroupTipState { set { groupTipState = value; } get { return groupTipState; } } [SerializeField] [Tooltip("Display the state of the master tooltip.")] private DisplayMode masterTipState; /// /// Display the state of the master tooltip. /// public DisplayMode MasterTipState { set { masterTipState = value; } get { return masterTipState; } } [SerializeField] [Tooltip("GameObject that the line and text are attached to")] private GameObject anchor; /// /// getter/setter for ameObject that the line and text are attached to /// public GameObject Anchor { get { return anchor; } set { anchor = value; } } [Tooltip("Pivot point that text will rotate around as well as the point where the Line will be rendered to.")] [SerializeField] private GameObject pivot; /// /// Pivot point that text will rotate around as well as the point where the Line will be rendered to. /// public GameObject Pivot => pivot; [SerializeField] [Tooltip("GameObject text that is displayed on the tooltip.")] private GameObject label; [SerializeField] [Tooltip("Parent of the Text and Background")] private GameObject contentParent; [TextArea] [SerializeField] [Tooltip("Text for the ToolTip to display")] private string toolTipText; /// /// Text for the ToolTip to display /// public string ToolTipText { set { toolTipText = value; if (!Application.isPlaying) { // Only force refresh in edit mode RefreshLocalContent(); } } get { return toolTipText; } } [SerializeField] [Tooltip("The padding around the content (height / width)")] private Vector2 backgroundPadding = Vector2.zero; [SerializeField] [Tooltip("The offset of the background (x / y / z)")] private Vector3 backgroundOffset = Vector3.zero; /// /// The offset of the background (x / y / z) /// public Vector3 LocalContentOffset => backgroundOffset; [SerializeField] [Range(0.01f, 3f)] [Tooltip("The scale of all the content (label, backgrounds, etc.)")] private float contentScale = 1f; /// /// The scale of all the content (label, backgrounds, etc.) /// public float ContentScale { get { return contentScale; } set { contentScale = value; if (!Application.isPlaying) { // Only force refresh in edit mode RefreshLocalContent(); } } } [SerializeField] [Range(10, 60)] [Tooltip("The font size of the tooltip.")] private int fontSize = 30; /// /// The font size of the tooltip. /// public int FontSize { get { return fontSize; } set { fontSize = value; if (!Application.isPlaying) { // Only force refresh in edit mode RefreshLocalContent(); } } } [SerializeField] [Tooltip("Determines where the line will attach to the tooltip content.")] private ToolTipAttachPoint attachPointType = ToolTipAttachPoint.Closest; public ToolTipAttachPoint PivotType { get { return attachPointType; } set { attachPointType = value; } } /// /// point where ToolTip is attached /// public Vector3 AttachPointPosition { get { return attachPointPosition; } set { // apply the difference to the offset attachPointOffset = value - contentParent.transform.TransformPoint(localAttachPoint); } } [SerializeField] [Tooltip("Added as an offset to the pivot position. Modifying AttachPointPosition directly changes this value.")] private Vector3 attachPointOffset; [SerializeField] [Tooltip("The line connecting the anchor to the pivot. If present, this component will be updated automatically.\n\nRecommended: SimpleLine, Spline, and ParabolaConstrainted")] private BaseMixedRealityLineDataProvider toolTipLine; private Vector2 localContentSize; /// /// getter/setter for size of tooltip. /// public Vector2 LocalContentSize => localContentSize; private Vector3 pivotPosition; private Vector3 attachPointPosition; private Vector3 anchorPosition; private Vector3 localAttachPoint; private Vector3[] localAttachPointPositions; private List backgrounds = new List(); private List highlights = new List(); private TextMeshPro cachedLabelText; private int prevTextLength = -1; private int prevTextHash = -1; private int prevFontSize = -1; /// /// point about which ToolTip pivots to face camera /// public Vector3 PivotPosition { get { return pivotPosition; } set { pivotPosition = value; pivot.transform.position = value; } } /// /// point where ToolTip connector is attached /// public Vector3 AnchorPosition { get { return anchorPosition; } set { anchor.transform.position = value; } } /// /// Transform of object to which ToolTip is attached /// public Transform ContentParentTransform => contentParent.transform; /// /// is ToolTip active and displaying /// public bool IsOn { get { return ResolveTipState(masterTipState, groupTipState, tipState, HasFocus); } } public static bool ResolveTipState(DisplayMode masterTipState, DisplayMode groupTipState, DisplayMode tipState, bool hasFocus) { switch (masterTipState) { case DisplayMode.None: default: // Use our group state switch (groupTipState) { case DisplayMode.None: default: // Use our local State switch (tipState) { case DisplayMode.None: case DisplayMode.Off: default: return false; case DisplayMode.On: return true; case DisplayMode.OnFocus: return hasFocus; } case DisplayMode.On: return true; case DisplayMode.Off: return false; case DisplayMode.OnFocus: return hasFocus; } case DisplayMode.On: return true; case DisplayMode.Off: return false; case DisplayMode.OnFocus: return hasFocus; } } /// /// does the ToolTip have focus. /// public virtual bool HasFocus { get { return false; } } /// /// virtual functions /// protected virtual void OnEnable() { ValidateHeirarchy(); label.EnsureComponent(); gameObject.EnsureComponent(); // Get our line if it exists if (toolTipLine == null) toolTipLine = gameObject.GetComponent(); // Make sure the tool tip text isn't empty if (string.IsNullOrEmpty(toolTipText)) toolTipText = " "; backgrounds.Clear(); foreach (IToolTipBackground background in GetComponents()) { backgrounds.Add(background); } highlights.Clear(); foreach (IToolTipHighlight highlight in GetComponents()) { highlights.Add(highlight); } contentParent.SetActive(false); ShowBackground = showBackground; ShowHighlight = showHighlight; ShowConnector = showConnector; } protected virtual void Update() { // Cache our pivot / anchor / attach point positions pivotPosition = pivot.transform.position; anchorPosition = anchor.transform.position; attachPointPosition = contentParent.transform.TransformPoint(localAttachPoint) + attachPointOffset; // Enable / disable our line if it exists if (toolTipLine != null) { toolTipLine.enabled = showConnector; if (!(toolTipLine is ParabolaConstrainedLineDataProvider)) { toolTipLine.FirstPoint = AnchorPosition; } toolTipLine.LastPoint = AttachPointPosition; } if (IsOn) { contentParent.SetActive(true); localAttachPoint = ToolTipUtility.FindClosestAttachPointToAnchor(anchor.transform, contentParent.transform, localAttachPointPositions, PivotType); } else { contentParent.SetActive(false); } RefreshLocalContent(); } protected virtual void RefreshLocalContent() { // Set the scale of the pivot contentParent.transform.localScale = Vector3.one * contentScale; label.transform.localScale = Vector3.one * 0.005f; // Set the content using a text mesh by default // This function can be overridden for tooltips that use Unity UI // Has content or fontSize changed? int currentTextLength = toolTipText.Length; int currentTextHash = toolTipText.GetHashCode(); int currentFontSize = fontSize; // If it has, update the content if (currentTextLength != prevTextLength || currentTextHash != prevTextHash || currentFontSize != prevFontSize) { prevTextHash = currentTextHash; prevTextLength = currentTextLength; prevFontSize = currentFontSize; if (cachedLabelText == null) cachedLabelText = label.GetComponent(); if (cachedLabelText != null && !string.IsNullOrEmpty(toolTipText)) { cachedLabelText.fontSize = fontSize; cachedLabelText.text = toolTipText.Trim(); // Force text mesh to use center alignment cachedLabelText.alignment = TextAlignmentOptions.CenterGeoAligned; // Update text so we get an accurate scale cachedLabelText.ForceMeshUpdate(); // Get the world scale of the text // Convert that to local scale using the content parent Vector3 localScale = Vector3.Scale(cachedLabelText.transform.lossyScale / contentScale, cachedLabelText.textBounds.size); localContentSize.x = localScale.x + backgroundPadding.x; localContentSize.y = localScale.y + backgroundPadding.y; } // Now that we have the size of our content, get our pivots ToolTipUtility.GetAttachPointPositions(ref localAttachPointPositions, localContentSize); localAttachPoint = ToolTipUtility.FindClosestAttachPointToAnchor(anchor.transform, contentParent.transform, localAttachPointPositions, PivotType); foreach (IToolTipBackground background in backgrounds) { background.OnContentChange(localContentSize, LocalContentOffset, contentParent.transform); } } foreach (IToolTipBackground background in backgrounds) { background.IsVisible = showBackground; } foreach (IToolTipHighlight highlight in highlights) { highlight.ShowHighlight = ShowHighlight; } } protected virtual bool EnforceHierarchy() { Transform pivotTransform = transform.Find("Pivot"); Transform anchorTransform = transform.Find("Anchor"); if (pivotTransform == null || anchorTransform == null) { if (Application.isPlaying) { Debug.LogError("Found error in hierarchy, disabling."); enabled = false; } return false; } Transform contentParentTransform = pivotTransform.Find("ContentParent"); if (contentParentTransform == null) { if (Application.isPlaying) { Debug.LogError("Found error in hierarchy, disabling."); enabled = false; } return false; } Transform labelTransform = contentParentTransform.Find("Label"); if (labelTransform == null) { if (Application.isPlaying) { Debug.LogError("Found error in hierarchy, disabling."); enabled = false; } return false; } contentParentTransform.localPosition = Vector3.zero; contentParentTransform.localRotation = Quaternion.identity; contentParentTransform.localScale = Vector3.one * contentScale; labelTransform.localPosition = Vector3.zero; labelTransform.localScale = Vector3.one * 0.025f; labelTransform.localRotation = Quaternion.identity; pivotTransform.localScale = Vector3.one; pivot = pivotTransform.gameObject; anchor = anchorTransform.gameObject; contentParent = contentParentTransform.gameObject; label = labelTransform.gameObject; return true; } public static Vector3 GetTextMeshLocalScale(TextMesh textMesh) { Vector3 localScale = Vector3.zero; if (string.IsNullOrEmpty(textMesh.text)) return localScale; string[] splitStrings = textMesh.text.Split(new string[] { System.Environment.NewLine, "\n" }, System.StringSplitOptions.RemoveEmptyEntries); // Calculate the width of the text using character info float widestLine = 0f; foreach (string splitString in splitStrings) { float lineWidth = 0f; foreach (char symbol in splitString) { CharacterInfo info; if (textMesh.font.GetCharacterInfo(symbol, out info, textMesh.fontSize, textMesh.fontStyle)) { lineWidth += info.advance; } } if (lineWidth > widestLine) widestLine = lineWidth; } localScale.x = widestLine; // Use this to multiply the character size Vector3 transformScale = textMesh.transform.localScale; localScale.x = (localScale.x * textMesh.characterSize * 0.1f) * transformScale.x; localScale.z = transformScale.z; // We could calculate the height based on line height and character size // But I've found that method can be flaky and has a lot of magic numbers // that may break in future Unity versions Vector3 eulerAngles = textMesh.transform.eulerAngles; Vector3 rendererScale = Vector3.zero; textMesh.transform.rotation = Quaternion.identity; rendererScale = textMesh.GetComponent().bounds.size; textMesh.transform.eulerAngles = eulerAngles; localScale.y = textMesh.transform.worldToLocalMatrix.MultiplyVector(rendererScale).y * transformScale.y; return localScale; } private void ValidateHeirarchy() { // Generate default objects if we haven't set up our tooltip yet if (anchor == null) { Transform anchorTransform = transform.Find("Anchor"); if (anchorTransform == null) { anchorTransform = new GameObject("Anchor").transform; anchorTransform.SetParent(transform); anchorTransform.localPosition = Vector3.zero; } anchor = anchorTransform.gameObject; } if (anchor.transform.parent != transform) anchor.transform.SetParent(transform); if (pivot == null) { Transform pivotTransform = transform.Find("Pivot"); if (pivotTransform == null) { pivotTransform = new GameObject("Pivot").transform; pivotTransform.SetParent(transform); pivotTransform.localPosition = Vector3.up; } pivot = pivotTransform.gameObject; } if (pivot.transform.parent != transform) pivot.transform.SetParent(transform, true); if (contentParent == null) { Transform contentParentTransform = pivot.transform.Find("ContentParent"); if (contentParentTransform == null) { contentParentTransform = new GameObject("ContentParent").transform; contentParentTransform.SetParent(pivot.transform); contentParentTransform.localPosition = Vector3.zero; } contentParent = contentParentTransform.gameObject; } if (contentParent.transform.parent != pivot.transform) contentParent.transform.SetParent(pivot.transform, true); if (label == null) { Transform labelTransform = contentParent.transform.Find("Label"); if (labelTransform == null) { labelTransform = new GameObject("Label").transform; labelTransform.SetParent(contentParent.transform); labelTransform.localScale = Vector3.one * 0.005f; labelTransform.localPosition = Vector3.zero; } label = labelTransform.gameObject; } if (label.transform.parent != contentParent.transform) label.transform.SetParent(contentParent.transform.parent, true); } } }