using System; using System.Collections.Generic; using TMPro; using UnityEngine.Pool; namespace UnityEngine.XR.Interaction.Toolkit.Samples.SpatialKeyboard { /// /// Virtual spatial keyboard. /// public class XRKeyboard : MonoBehaviour { /// /// Layout this keyboard is able to switch to with the corresponding layout command. /// /// [Serializable] public struct SubsetMapping { [SerializeField, Tooltip("This drives what GameObject layout is displayed.")] string m_LayoutString; /// /// This drives what GameObject layout is displayed. /// public string layoutString { get => m_LayoutString; set => m_LayoutString = value; } [SerializeField, Tooltip("GameObject root of the layout which contains the set of keys.")] XRKeyboardLayout m_LayoutRoot; /// /// GameObject root of the layout which contains the set of keys. /// public XRKeyboardLayout layoutRoot { get => m_LayoutRoot; set => m_LayoutRoot = value; } [SerializeField, Tooltip("Config asset which contains the key definitions for the layout when this is turned on.")] XRKeyboardConfig m_ToggleOnConfig; /// /// Config asset which contains the key definitions for the layout when this is turned on. /// public XRKeyboardConfig toggleOnConfig { get => m_ToggleOnConfig; set => m_ToggleOnConfig = value; } [SerializeField, Tooltip("Config asset which is the default config when this is turned off.")] XRKeyboardConfig m_ToggleOffConfig; /// /// Config asset which is the default config when this is turned off. /// public XRKeyboardConfig toggleOffConfig { get => m_ToggleOffConfig; set => m_ToggleOffConfig = value; } } [SerializeField, HideInInspector] string m_Text = string.Empty; /// /// String of text currently in the keyboard. Setter invokes when updated. /// public string text { get => m_Text; protected set { if (m_Text != value) { m_Text = value; caretPosition = Math.Clamp(caretPosition, 0, m_Text.Length); using (m_KeyboardTextEventArgs.Get(out var args)) { args.keyboard = this; args.keyboardText = text; onTextUpdated?.Invoke(args); } } } } [SerializeField, HideInInspector] TMP_InputField m_CurrentInputField; /// /// Current input field this keyboard is observing. /// protected TMP_InputField currentInputField { get => m_CurrentInputField; set { if (m_CurrentInputField == value) return; StopObservingInputField(m_CurrentInputField); m_CurrentInputField = value; StartObservingInputField(m_CurrentInputField); using (m_KeyboardTextEventArgs.Get(out var args)) { args.keyboard = this; args.keyboardText = text; onFocusChanged?.Invoke(args); } } } [SerializeField] KeyboardTextEvent m_OnTextSubmitted = new KeyboardTextEvent(); /// /// Event invoked when keyboard submits text. /// public KeyboardTextEvent onTextSubmitted { get => m_OnTextSubmitted; set => m_OnTextSubmitted = value; } [SerializeField] KeyboardTextEvent m_OnTextUpdated = new KeyboardTextEvent(); /// /// Event invoked when keyboard text is updated. /// public KeyboardTextEvent onTextUpdated { get => m_OnTextUpdated; set => m_OnTextUpdated = value; } [SerializeField] KeyboardKeyEvent m_OnKeyPressed = new KeyboardKeyEvent(); /// /// Event invoked after a key is pressed. /// public KeyboardKeyEvent onKeyPressed { get => m_OnKeyPressed; set => m_OnKeyPressed = value; } [SerializeField] KeyboardModifiersEvent m_OnShifted = new KeyboardModifiersEvent(); /// /// Event invoked after keyboard shift is changed. These event args also contain the value for the caps lock state. /// public KeyboardModifiersEvent onShifted { get => m_OnShifted; set => m_OnShifted = value; } [SerializeField] KeyboardLayoutEvent m_OnLayoutChanged = new KeyboardLayoutEvent(); /// /// Event invoked when keyboard layout is changed. /// public KeyboardLayoutEvent onLayoutChanged { get => m_OnLayoutChanged; set => m_OnLayoutChanged = value; } [SerializeField] KeyboardTextEvent m_OnOpened = new KeyboardTextEvent(); /// /// Event invoked when the keyboard is opened. /// public KeyboardTextEvent onOpened { get => m_OnOpened; set => m_OnOpened = value; } [SerializeField] KeyboardTextEvent m_OnClosed; /// /// Event invoked after the keyboard is closed. /// public KeyboardTextEvent onClosed { get => m_OnClosed; set => m_OnClosed = value; } [SerializeField] KeyboardTextEvent m_OnFocusChanged = new KeyboardTextEvent(); /// /// Event invoked when the keyboard changes or gains input field focus. /// public KeyboardTextEvent onFocusChanged { get => m_OnFocusChanged; set => m_OnFocusChanged = value; } [SerializeField] KeyboardEvent m_OnCharacterLimitReached = new KeyboardEvent(); /// /// Event invoked when the keyboard tries to update text, but the character of the input field is reached. /// public KeyboardEvent onCharacterLimitReached { get => m_OnCharacterLimitReached; set => m_OnCharacterLimitReached = value; } [SerializeField] bool m_SubmitOnEnter = true; /// /// If true, will be invoked when the keyboard receives a return or enter command. Otherwise, /// it will treat return or enter as a newline. /// public bool submitOnEnter { get => m_SubmitOnEnter; set => m_SubmitOnEnter = value; } [SerializeField] bool m_CloseOnSubmit; /// /// If true, keyboard will close on enter or return command. /// public bool closeOnSubmit { get => m_CloseOnSubmit; set => m_CloseOnSubmit = value; } [SerializeField] float m_DoubleClickInterval = 2f; /// /// Interval in which a key pressed twice would be considered a double click. /// public float doubleClickInterval { get => m_DoubleClickInterval; set => m_DoubleClickInterval = value; } [SerializeField] List m_SubsetLayout; /// /// List of layouts this keyboard is able to switch between given the corresponding layout command. /// /// This supports multiple layout roots updating with the same . public List subsetLayout { get => m_SubsetLayout; set => m_SubsetLayout = value; } /// /// List of keys associated with this keyboard. /// public List keys { get; set; } int m_CaretPosition; /// /// Caret index of this keyboard. /// public int caretPosition { get => m_CaretPosition; protected set => m_CaretPosition = value; } bool m_Shifted; /// /// (Read Only) Gets the shift state of the keyboard. /// public bool shifted => m_Shifted; bool m_CapsLocked; /// /// (Read Only) Gets the caps lock state of the keyboard. /// public bool capsLocked => m_CapsLocked; bool m_IsOpen; /// /// Returns true if the keyboard has been opened with the open function and the keyboard is active and enabled, otherwise returns false. /// public bool isOpen => (m_IsOpen && isActiveAndEnabled); Dictionary> m_SubsetLayoutMap; HashSet m_KeyboardLayouts; // Reusable event args readonly LinkedPool m_KeyboardTextEventArgs = new LinkedPool(() => new KeyboardTextEventArgs(), collectionCheck: false); readonly LinkedPool m_KeyboardLayoutEventArgs = new LinkedPool(() => new KeyboardLayoutEventArgs(), collectionCheck: false); readonly LinkedPool m_KeyboardModifiersEventArgs = new LinkedPool(() => new KeyboardModifiersEventArgs(), collectionCheck: false); readonly LinkedPool m_KeyboardKeyEventArgs = new LinkedPool(() => new KeyboardKeyEventArgs(), collectionCheck: false); readonly LinkedPool m_KeyboardBaseEventArgs = new LinkedPool(() => new KeyboardBaseEventArgs(), collectionCheck: false); int m_CharacterLimit = -1; bool m_MonitorCharacterLimit; /// /// See . /// void Awake() { m_SubsetLayoutMap = new Dictionary>(); m_KeyboardLayouts = new HashSet(); foreach (var subsetMapping in m_SubsetLayout) { if (m_SubsetLayoutMap.TryGetValue(subsetMapping.layoutString, out var subsetMappings)) subsetMappings.Add(subsetMapping); else m_SubsetLayoutMap[subsetMapping.layoutString] = new List { subsetMapping }; m_KeyboardLayouts.Add(subsetMapping.layoutRoot); } keys = new List(); GetComponentsInChildren(true, keys); keys.ForEach(key => key.keyboard = this); } /// /// See . /// void OnDisable() { // Reset if this component is turned off without first calling close function m_IsOpen = false; } /// /// Processes a . /// /// Key code to process. /// True on supported KeyCode. /// /// Override this method to add support for additional . /// public virtual bool ProcessKeyCode(KeyCode keyCode) { var success = true; switch (keyCode) { case KeyCode.LeftShift: case KeyCode.RightShift: Shift(!m_Shifted); break; case KeyCode.CapsLock: CapsLock(!m_CapsLocked); break; case KeyCode.Backspace: Backspace(); break; case KeyCode.Delete: Delete(); break; case KeyCode.Clear: Clear(); break; case KeyCode.Space: UpdateText(" "); break; case KeyCode.Return: case KeyCode.KeypadEnter: if (submitOnEnter) { Submit(); } else { UpdateText("\n"); } break; default: success = false; break; } return success; } /// /// Attempts to process the key based on the key's character. Used as a fallback when KeyFunction is /// empty on the key. /// /// Key to attempt to process public virtual void TryProcessKeyPress(XRKeyboardKey key) { if (key == null || !ReferenceEquals(key.keyboard, this)) return; // Process key stroke if (onKeyPressed != null) { // Try to process key code if (ProcessKeyCode(key.keyCode)) return; var keyPress = key.GetEffectiveCharacter(); // Monitor for subset change if (UpdateLayout(keyPress)) return; switch (keyPress) { case "\\s": // Shift Shift(!m_Shifted); break; case "\\caps": CapsLock(!m_CapsLocked); break; case "\\b": // Backspace Backspace(); break; case "\\c": // cancel break; case "\\r" when submitOnEnter: { Submit(); break; } case "\\cl": // Clear Clear(); break; case "\\h": // Hide Close(); break; default: { UpdateText(keyPress); break; } } } } /// /// Pre-process function when a key is pressed. /// /// Key that is about to process. public virtual void PreprocessKeyPress(XRKeyboardKey key) { } /// /// Post-process function when a key is pressed. /// /// Key that has just been processed. public virtual void PostprocessKeyPress(XRKeyboardKey key) { using (m_KeyboardKeyEventArgs.Get(out var args)) { args.keyboard = this; args.key = key; onKeyPressed.Invoke(args); } } #region Process Key Functions /// /// Updates the keyboard text by inserting the string into the existing . /// /// The new text to insert into the current keyboard text. /// If the keyboard is set to monitor the input field's character limit, the keyboard will ensure /// the text does not exceed the . public virtual void UpdateText(string newText) { // Attempt to add key press to current text var updatedText = text; updatedText = updatedText.Insert(caretPosition, newText); var isUpdatedTextWithinLimits = !m_MonitorCharacterLimit || updatedText.Length <= m_CharacterLimit; if (isUpdatedTextWithinLimits) { caretPosition += newText.Length; text = updatedText; } else { using (m_KeyboardBaseEventArgs.Get(out var args)) { args.keyboard = this; onCharacterLimitReached?.Invoke(args); } } // Turn off shift after typing a letter if (m_Shifted && !m_CapsLocked) Shift(!m_Shifted); } /// /// Process shift command for keyboard. /// public virtual void Shift(bool shiftValue) { m_Shifted = shiftValue; using (m_KeyboardModifiersEventArgs.Get(out var args)) { args.keyboard = this; args.shiftValue = m_Shifted; args.capsLockValue = m_CapsLocked; onShifted.Invoke(args); } } /// /// Process caps lock command for keyboard. /// public virtual void CapsLock(bool capsLockValue) { m_CapsLocked = capsLockValue; Shift(capsLockValue); } /// /// Process backspace command for keyboard. /// public virtual void Backspace() { if (caretPosition > 0) { --caretPosition; text = text.Remove(caretPosition, 1); } } /// /// Process delete command for keyboard and deletes one character. /// public virtual void Delete() { if (caretPosition < text.Length) { text = text.Remove(caretPosition, 1); } } /// /// Invokes event and closes keyboard if is true. /// public virtual void Submit() { using (m_KeyboardTextEventArgs.Get(out var args)) { args.keyboard = this; args.keyboardText = text; onTextSubmitted?.Invoke(args); } if (closeOnSubmit) Close(false); } /// /// Clears text to an empty string. /// public virtual void Clear() { text = string.Empty; caretPosition = text.Length; } /// /// Looks up the associated with the and updates the /// on the . If the /// is already , /// will be set as the active key mapping. /// /// The string of the new layout as it is registered in the . /// Returns true if the layout was successfully found and changed. /// By default, shift or caps lock will be turned off on layout change. public virtual bool UpdateLayout(string layoutKey) { if (m_SubsetLayoutMap.TryGetValue(layoutKey, out var subsetMappings)) { foreach (var subsetMapping in subsetMappings) { var layout = subsetMapping.layoutRoot; layout.activeKeyMapping = layout.activeKeyMapping != subsetMapping.toggleOnConfig ? subsetMapping.toggleOnConfig : subsetMapping.toggleOffConfig; } if (m_Shifted || m_CapsLocked) CapsLock(false); using (m_KeyboardLayoutEventArgs.Get(out var args)) { args.keyboard = this; args.layout = layoutKey; onLayoutChanged.Invoke(args); } return true; } return false; } #endregion #region Open Functions /// /// Opens the keyboard with a parameter as the active input field. /// /// The input field opening this keyboard. /// If true, keyboard will observe the character limit from the . public virtual void Open(TMP_InputField inputField, bool observeCharacterLimit = false) { currentInputField = inputField; m_MonitorCharacterLimit = observeCharacterLimit; m_CharacterLimit = observeCharacterLimit ? currentInputField.characterLimit : -1; Open(currentInputField.text); } /// /// Opens the keyboard with any existing text. /// /// /// Shortcut for Open(text). /// public void Open() => Open(text); /// /// Opens the keyboard with an empty string and clear any existing text in the input field or keyboard. /// /// /// Shortcut for Open(string.Empty). /// public void OpenCleared() => Open(string.Empty); /// /// Opens the keyboard with a given string to populate the keyboard text. /// /// Text string to set the keyboard to. /// The event is fired before the text is updating with /// to give any observers that would be listening the opportunity to close and stop observing before the text is updated. /// This is a common use case for any utilizing the global keyboard. public virtual void Open(string newText) { if (!isActiveAndEnabled) { // Fire event before updating text because any displays observing keyboards will be listening to that text change // This gives them the opportunity to close and stop observing before the text is updated. using (m_KeyboardTextEventArgs.Get(out var args)) { args.keyboard = this; args.keyboardText = text; onOpened?.Invoke(args); } } caretPosition = newText.Length; text = newText; gameObject.SetActive(true); m_IsOpen = true; } #endregion #region Close Functions /// /// Process close command for keyboard. /// /// Stops observing active input field, resets variables, and hides this GameObject. public virtual void Close() { // Clear any input field the keyboard is observing currentInputField = null; m_MonitorCharacterLimit = false; m_CharacterLimit = -1; if (m_Shifted || m_CapsLocked) CapsLock(false); using (m_KeyboardTextEventArgs.Get(out var args)) { args.keyboard = this; args.keyboardText = text; onClosed?.Invoke(args); } gameObject.SetActive(false); m_IsOpen = false; } /// /// Process close command for keyboard. Optional overload for clearing text and resetting layout on close. /// /// If true, text will be cleared upon keyboard closing. This will happen after the /// event is fired so the observers have time to stop listening. /// If true, each will reset to the . /// Please note, if is true, the text will be cleared and the /// event will be fired. This means any observers will be notified of an empty string. To avoid unwanted behavior of /// the text clearing, use the event to unsubscribe to the keyboard events before the text is cleared. public virtual void Close(bool clearText, bool resetLayout = true) { Close(); if (clearText) text = string.Empty; // Reset keyboard layout on close if (resetLayout) { // Loop through each layout root and reset to default layouts foreach (var layoutRoot in m_KeyboardLayouts) { layoutRoot.SetDefaultLayout(); } // Fire event of layout change to ensure highlighted buttons are reset using (m_KeyboardLayoutEventArgs.Get(out var args)) { args.keyboard = this; args.layout = "default"; onLayoutChanged.Invoke(args); } } } #endregion #region Input Field Handling protected virtual void StopObservingInputField(TMP_InputField inputField) { if (inputField == null) return; currentInputField.onValueChanged.RemoveListener(OnInputFieldValueChange); } protected virtual void StartObservingInputField(TMP_InputField inputField) { if (inputField == null) return; currentInputField.onValueChanged.AddListener(OnInputFieldValueChange); } /// /// Callback method invoked when the input field's text value changes. /// /// The text of the input field. protected virtual void OnInputFieldValueChange(string updatedText) { caretPosition = updatedText.Length; text = updatedText; } #endregion } }