// 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) { }
}
}