// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.MixedReality.Toolkit.Input; using UnityEngine; using UnityEngine.Serialization; namespace Microsoft.MixedReality.Toolkit.Dwell { /// /// Use this component to add a Dwell modality (https://docs.microsoft.com/windows/mixed-reality/gaze-and-dwell) to the UI target. /// [AddComponentMenu("Scripts/MRTK/SDK/DwellHandler")] public class DwellHandler : MonoBehaviour, IMixedRealityFocusChangedHandler { [Tooltip("The profile to use with this handler")] [Header("Dwell Settings")] [SerializeField] [FormerlySerializedAs("DwellProfile")] protected DwellProfile dwellProfile = null; [Tooltip("The event to trigger when being focused longer than the DwellIntentDelay")] [Header("Dwell Events")] [SerializeField] [FormerlySerializedAs("DwellIntended")] private DwellUnityEvent dwellIntended = new DwellUnityEvent(); [Tooltip("The event to trigger when being focused longer than the DwellStartDelay after the DwellIntentDelay")] [SerializeField] [FormerlySerializedAs("DwellStarted")] private DwellUnityEvent dwellStarted = new DwellUnityEvent(); [Tooltip("The event to trigger when being focused longer than the TimeToCompleteDwell after the DwellStartDelay")] [SerializeField] [FormerlySerializedAs("DwellCompleted")] private DwellUnityEvent dwellCompleted = new DwellUnityEvent(); [Tooltip("The event to trigger when losing focus while being in the dwell started state")] [SerializeField] [FormerlySerializedAs("DwellCanceled")] private DwellUnityEvent dwellCanceled = new DwellUnityEvent(); /// /// The profile to use with this handler /// public DwellProfile DwellProfile { get => dwellProfile; set => dwellProfile = value; } /// /// The event to trigger when being focused longer than the DwellIntentDelay /// public DwellUnityEvent DwellIntended { get => dwellIntended; set => dwellIntended = value; } /// /// The event to trigger when being focused longer than the DwellStartDelay after the DwellIntentDelay /// public DwellUnityEvent DwellStarted { get => dwellStarted; set => dwellStarted = value; } /// /// The event to trigger when being focused longer than the TimeToCompleteDwell after the DwellStartDelay /// public DwellUnityEvent DwellCompleted { get => dwellCompleted; set => dwellCompleted = value; } /// /// The event to trigger when losing focus while being in the dwell started state /// public DwellUnityEvent DwellCanceled { get => dwellCanceled; set => dwellCanceled = value; } /// /// Captures the dwell status /// public DwellStateType CurrentDwellState { get; protected set; } = DwellStateType.None; /// /// Property exposing the computation for what percentage of dwell has progressed, ranging from 0 to 1. /// public virtual float DwellProgress { get { switch (CurrentDwellState) { case DwellStateType.None: case DwellStateType.FocusGained: return 0; case DwellStateType.DwellStarted: return GetCurrentDwellProgress(); case DwellStateType.DwellCompleted: return 1; case DwellStateType.DwellCanceled: if (dwellProfile.TimeToAllowDwellResume > 0) { return GetCurrentDwellProgress(); } break; default: return 0; } return 0; } } /// /// Exposes whether the target has focus from the pointer type defined in dwell profile settings /// protected bool HasFocus { get; private set; } /// /// Abstracted value for the how long the dwelled object still needs to be focused to complete the dwell action /// Value ranges from 0 to "TimeToCompleteDwell" setting in the dwellprofile. This picks up the same unit as TimeToCompleteDwell /// protected float FillTimer { get; set; } = 0; /// /// Cached pointer reference to track focus events maps to the same pointer id that initiated dwell /// private IMixedRealityPointer pointer; private int pointerCount = 0; private float focusEnterTime = float.MaxValue; private float focusExitTime = float.MaxValue; private void Awake() { Debug.Assert(dwellProfile != null, "DwellProfile is null, creating default profile."); if (dwellProfile == null) { dwellProfile = ScriptableObject.CreateInstance(); } } /// /// Valid state transitions for default implementation /// private void Update() { UpdateFillTimer(); if (HasFocus && CurrentDwellState != DwellStateType.DwellCompleted) { float focusDuration = Time.time - focusEnterTime; if (CurrentDwellState == DwellStateType.FocusGained && focusDuration >= dwellProfile.DwellIntentDelay) { CurrentDwellState = DwellStateType.DwellIntended; dwellIntended.Invoke(pointer); } else if (CurrentDwellState == DwellStateType.DwellIntended && (focusDuration - dwellProfile.DwellIntentDelay) >= dwellProfile.DwellStartDelay) { CurrentDwellState = DwellStateType.DwellStarted; dwellStarted.Invoke(pointer); } else if (CurrentDwellState == DwellStateType.DwellStarted && FillTimer >= dwellProfile.TimeToCompleteDwell) { CurrentDwellState = DwellStateType.DwellCompleted; dwellCompleted.Invoke(pointer); } } } /// /// Get the current progess of dwell. Return value ranges from 0 to 1. /// protected float GetCurrentDwellProgress() { return Mathf.Clamp(FillTimer / dwellProfile.TimeToCompleteDwell, 0f, 1f); } /// /// Default FillTimer computation based on profile settings /// protected virtual void UpdateFillTimer() { switch (CurrentDwellState) { case DwellStateType.None: case DwellStateType.FocusGained: case DwellStateType.DwellIntended: FillTimer = 0; break; case DwellStateType.DwellStarted: FillTimer += Time.deltaTime; break; case DwellStateType.DwellCompleted: break; case DwellStateType.DwellCanceled: // this is a conditional state transition and can be overridden by the deriving class as per profile settings. bool dwellCompleted = false; if (dwellProfile.DecayDwellOverTime) { FillTimer -= Time.deltaTime * dwellProfile.TimeToCompleteDwell / dwellProfile.TimeToAllowDwellResume; dwellCompleted = FillTimer <= 0; } else { dwellCompleted = (Time.time - focusExitTime) > dwellProfile.TimeToAllowDwellResume; } if (FillTimer <= 0) { FillTimer = 0; CurrentDwellState = DwellStateType.None; } break; default: FillTimer = 0; break; } } /// public void OnFocusChanged(FocusEventData eventData) { InputSourceType inputSourceType = eventData.Pointer is GGVPointer ? InputSourceType.Head : eventData.Pointer.InputSourceParent.SourceType; if (eventData.NewFocusedObject == gameObject && inputSourceType == dwellProfile.DwellPointerType) { if (!HasFocus) { HasFocus = true; // check intent to resume if (CurrentDwellState == DwellStateType.DwellCanceled && (Time.time - focusExitTime) <= dwellProfile.TimeToAllowDwellResume) { // Add the time duration focus was away since this is a dwell resume and we need to account for the time that focus was lost for the target. // Assigning this the current time would restart computation for dwell progress. focusEnterTime += Time.time - focusExitTime; CurrentDwellState = DwellStateType.DwellStarted; dwellStarted.Invoke(pointer); } // dwell state machine re-starts else if (CurrentDwellState <= DwellStateType.DwellIntended) { focusEnterTime = Time.time; CurrentDwellState = DwellStateType.FocusGained; pointer = eventData.Pointer; FillTimer = 0; } } pointerCount++; } else if (eventData.OldFocusedObject == gameObject && inputSourceType == dwellProfile.DwellPointerType) { pointerCount--; if (pointerCount == 0) { HasFocus = false; if (CurrentDwellState == DwellStateType.DwellStarted) { dwellCanceled.Invoke(eventData.Pointer); CurrentDwellState = DwellStateType.DwellCanceled; focusExitTime = Time.time; } else { CurrentDwellState = DwellStateType.None; focusExitTime = float.MaxValue; } } } } /// /// Method that can be invoked if external factors (e.g. alternate input modality preemptively invoked the target) force the dwell action to prematurely end /// public virtual void CancelDwell() { dwellCanceled.Invoke(pointer); focusEnterTime = float.MaxValue; CurrentDwellState = DwellStateType.None; focusExitTime = float.MaxValue; FillTimer = 0; } /// public void OnBeforeFocusChange(FocusEventData eventData) { } } }