using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Input.Touch; using MLEM.Input; using MLEM.Misc; using MLEM.Ui.Elements; using MLEM.Ui.Style; namespace MLEM.Ui { /// /// UiControls holds and manages all of the controls for a . /// UiControls supports keyboard, mouse, gamepad and touch input using an underlying . /// public class UiControls { /// /// The input handler that is used for querying input /// public readonly InputHandler Input; /// /// A list of , and/or that act as the buttons on the keyboard which perform the action. /// If the is held, these buttons perform . /// public readonly Keybind KeyboardButtons = new Keybind().Add(Keys.Space).Add(Keys.Enter); /// /// AA that acts as the buttons on a gamepad that perform the action. /// public readonly Keybind GamepadButtons = new Keybind(Buttons.A); /// /// A that acts as the buttons on a gamepad that perform the action. /// public readonly Keybind SecondaryGamepadButtons = new Keybind(Buttons.X); /// /// A that acts as the buttons that select a that is above the currently selected element. /// public readonly Keybind UpButtons = new Keybind().Add(Buttons.DPadUp).Add(Buttons.LeftThumbstickUp); /// /// A that acts as the buttons that select a that is below the currently selected element. /// public readonly Keybind DownButtons = new Keybind().Add(Buttons.DPadDown).Add(Buttons.LeftThumbstickDown); /// /// A that acts as the buttons that select a that is to the left of the currently selected element. /// public readonly Keybind LeftButtons = new Keybind().Add(Buttons.DPadLeft).Add(Buttons.LeftThumbstickLeft); /// /// A that acts as the buttons that select a that is to the right of the currently selected element. /// public readonly Keybind RightButtons = new Keybind().Add(Buttons.DPadRight).Add(Buttons.LeftThumbstickRight); /// /// All instances used by these ui controls. /// This can be used to easily serialize and deserialize all ui keybinds. /// public readonly Keybind[] Keybinds; /// /// The that is currently active. /// The active root element is the one with the highest that . /// public RootElement ActiveRoot { get; protected set; } /// /// The that the mouse is currently over. /// public Element MousedElement { get; protected set; } /// /// The that is currently touched. /// public Element TouchedElement { get; protected set; } /// /// The element that is currently selected. /// This is the of the . /// public Element SelectedElement => this.GetSelectedElement(this.ActiveRoot); /// /// The zero-based index of the used for gamepad input. /// If this index is lower than 0, every connected gamepad will trigger input. /// public int GamepadIndex = -1; /// /// Set this to false to disable mouse input for these ui controls. /// Note that this does not disable mouse input for the underlying . /// public bool HandleMouse = true; /// /// Set this to false to disable keyboard input for these ui controls. /// Note that this does not disable keyboard input for the underlying . /// public bool HandleKeyboard = true; /// /// Set this to false to disable touch input for these ui controls. /// Note that this does not disable touch input for the underlying . /// public bool HandleTouch = true; /// /// Set this to false to disable gamepad input for these ui controls. /// Note that this does not disable gamepad input for the underlying . /// public bool HandleGamepad = true; /// /// If this value is true, the ui controls are in automatic navigation mode. /// This means that the will be drawn around the . /// public bool IsAutoNavMode { get => this.isAutoNavMode; set { if (this.isAutoNavMode != value) { this.isAutoNavMode = value; this.AutoNavModeChanged?.Invoke(value); } } } /// /// An event that is raised when is changed. /// This can be used for custom actions like hiding the mouse cursor when automatic navigation is enabled. /// public event Action AutoNavModeChanged; /// /// This value ist true if the was created by this ui controls instance, or if it was passed in. /// If the input handler was created by this instance, its method should be called by us. /// protected readonly bool IsInputOurs; /// /// The that this ui controls instance is controlling /// protected readonly UiSystem System; private readonly Dictionary selectedElements = new Dictionary(); private bool isAutoNavMode; /// /// Creates a new instance of the ui controls. /// You should rarely have to invoke this manually, since the handles it. /// /// The ui system to control with these controls /// The input handler to use for controlling, or null to create a new one. public UiControls(UiSystem system, InputHandler inputHandler = null) { this.System = system; this.Input = inputHandler ?? new InputHandler(system.Game); this.IsInputOurs = inputHandler == null; this.Keybinds = typeof(UiControls).GetFields() .Where(f => f.FieldType == typeof(Keybind)) .Select(f => (Keybind) f.GetValue(this)).ToArray(); // enable all required gestures InputHandler.EnableGestures(GestureType.Tap, GestureType.Hold); } /// /// Update this ui controls instance, causing the underlying to be updated, as well as ui input to be queried. /// public virtual void Update() { if (this.IsInputOurs) this.Input.Update(); this.ActiveRoot = this.System.GetRootElements().FirstOrDefault(root => root.CanBeActive); // MOUSE INPUT if (this.HandleMouse) { var mousedNow = this.GetElementUnderPos(new Vector2(this.Input.ViewportMousePosition.X, this.Input.ViewportMousePosition.Y)); this.SetMousedElement(mousedNow); if (this.Input.IsMouseButtonPressedAvailable(MouseButton.Left)) { this.IsAutoNavMode = false; var selectedNow = mousedNow != null && mousedNow.CanBeSelected ? mousedNow : null; this.SelectElement(this.ActiveRoot, selectedNow); if (mousedNow != null && mousedNow.CanBePressed) { this.System.InvokeOnElementPressed(mousedNow); this.Input.TryConsumeMouseButtonPressed(MouseButton.Left); } } else if (this.Input.IsMouseButtonPressedAvailable(MouseButton.Right)) { this.IsAutoNavMode = false; if (mousedNow != null && mousedNow.CanBePressed) { this.System.InvokeOnElementSecondaryPressed(mousedNow); this.Input.TryConsumeMouseButtonPressed(MouseButton.Right); } } } // KEYBOARD INPUT if (this.HandleKeyboard) { if (this.KeyboardButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) { if (this.SelectedElement?.Root != null && this.SelectedElement.CanBePressed) { if (this.Input.IsModifierKeyDown(ModifierKey.Shift)) { // secondary action on element using space or enter this.System.InvokeOnElementSecondaryPressed(this.SelectedElement); } else { // first action on element using space or enter this.System.InvokeOnElementPressed(this.SelectedElement); } this.KeyboardButtons.TryConsumePressed(this.Input, this.GamepadIndex); } } else if (this.Input.IsKeyPressedAvailable(Keys.Tab)) { this.IsAutoNavMode = true; // tab or shift-tab to next or previous element var backward = this.Input.IsModifierKeyDown(ModifierKey.Shift); var next = this.GetTabNextElement(backward); if (this.SelectedElement?.Root != null) next = this.SelectedElement.GetTabNextElement(backward, next); if (next != this.SelectedElement) { this.SelectElement(this.ActiveRoot, next); this.Input.TryConsumeKeyPressed(Keys.Tab); } } } // TOUCH INPUT if (this.HandleTouch) { if (this.Input.GetViewportGesture(GestureType.Tap, out var tap)) { this.IsAutoNavMode = false; var tapped = this.GetElementUnderPos(tap.Position); this.SelectElement(this.ActiveRoot, tapped); if (tapped != null && tapped.CanBePressed) this.System.InvokeOnElementPressed(tapped); } else if (this.Input.GetViewportGesture(GestureType.Hold, out var hold)) { this.IsAutoNavMode = false; var held = this.GetElementUnderPos(hold.Position); this.SelectElement(this.ActiveRoot, held); if (held != null && held.CanBePressed) this.System.InvokeOnElementSecondaryPressed(held); } else if (this.Input.ViewportTouchState.Count <= 0) { this.SetTouchedElement(null); } else { foreach (var location in this.Input.ViewportTouchState) { var element = this.GetElementUnderPos(location.Position); if (location.State == TouchLocationState.Pressed) { // start touching an element if we just touched down on it this.SetTouchedElement(element); } else if (element != this.TouchedElement) { // if we moved off of the touched element, we stop touching this.SetTouchedElement(null); } } } } // GAMEPAD INPUT if (this.HandleGamepad) { if (this.GamepadButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) { if (this.SelectedElement?.Root != null && this.SelectedElement.CanBePressed) { this.System.InvokeOnElementPressed(this.SelectedElement); this.GamepadButtons.TryConsumePressed(this.Input, this.GamepadIndex); } } else if (this.SecondaryGamepadButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) { if (this.SelectedElement?.Root != null && this.SelectedElement.CanBePressed) { this.System.InvokeOnElementSecondaryPressed(this.SelectedElement); this.SecondaryGamepadButtons.TryConsumePressed(this.Input, this.GamepadIndex); } } else if (this.DownButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) { if (this.HandleGamepadNextElement(Direction2.Down)) this.DownButtons.TryConsumePressed(this.Input, this.GamepadIndex); } else if (this.LeftButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) { if (this.HandleGamepadNextElement(Direction2.Left)) this.LeftButtons.TryConsumePressed(this.Input, this.GamepadIndex); } else if (this.RightButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) { if (this.HandleGamepadNextElement(Direction2.Right)) this.RightButtons.TryConsumePressed(this.Input, this.GamepadIndex); } else if (this.UpButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) { if (this.HandleGamepadNextElement(Direction2.Up)) this.UpButtons.TryConsumePressed(this.Input, this.GamepadIndex); } } } /// /// Returns the in the underlying that is currently below the given position. /// Throughout the ui system, this is used for mouse input querying. /// /// The position to query /// The element under the position, or null if there isn't one public virtual Element GetElementUnderPos(Vector2 position) { foreach (var root in this.System.GetRootElements()) { var pos = Vector2.Transform(position, root.InvTransform); var moused = root.Element.GetElementUnderPos(pos); if (moused != null) return moused; } return null; } /// /// Selects the given element that is a child of the given root element. /// Optionally, automatic navigation can be forced on, causing the to be drawn around the element. /// A simpler version of this method is . /// /// The root element of the /// The element to select, or null to deselect the selected element. /// Whether automatic navigation should be forced on public void SelectElement(RootElement root, Element element, bool? autoNav = null) { if (root == null) return; if (element != null && !element.CanBeSelected) return; var selected = this.GetSelectedElement(root); if (selected == element) return; if (selected != null) this.System.InvokeOnElementDeselected(selected); if (element != null) { this.System.InvokeOnElementSelected(element); this.selectedElements[root.Name] = element; } else { this.selectedElements.Remove(root.Name); } this.System.InvokeOnSelectedElementChanged(element); if (autoNav != null) this.IsAutoNavMode = autoNav.Value; } /// /// Sets the to the given value, calling the appropriate events. /// /// The element to set as moused public void SetMousedElement(Element element) { if (element != null && !element.CanBeMoused) return; if (element != this.MousedElement) { if (this.MousedElement != null) this.System.InvokeOnElementMouseExit(this.MousedElement); if (element != null) this.System.InvokeOnElementMouseEnter(element); this.MousedElement = element; this.System.InvokeOnMousedElementChanged(element); } } /// /// Sets the to the given value, calling the appropriate events. /// /// The element to set as touched public void SetTouchedElement(Element element) { if (element != null && !element.CanBeMoused) return; if (element != this.TouchedElement) { if (this.TouchedElement != null) this.System.InvokeOnElementTouchExit(this.TouchedElement); if (element != null) this.System.InvokeOnElementTouchEnter(element); this.TouchedElement = element; this.System.InvokeOnTouchedElementChanged(element); } } /// /// Returns the selected element for the given root element. /// A property equivalent to this method is . /// /// The root element whose selected element to return /// The given root's selected element, or null if the root doesn't exist, or if there is no selected element for that root. public Element GetSelectedElement(RootElement root) { if (root == null) return null; this.selectedElements.TryGetValue(root.Name, out var element); return element; } /// /// Returns the next element to select when pressing the key during keyboard navigation. /// If the backward boolean is true, the previous element should be returned instead. /// /// If we're going backwards (if is held) /// The next or previous element to select protected virtual Element GetTabNextElement(bool backward) { if (this.ActiveRoot == null) return null; var children = this.ActiveRoot.Element.GetChildren(c => !c.IsHidden, true, true).Concat(Enumerable.Repeat(this.ActiveRoot.Element, 1)) // we can't add these checks to GetChildren because it ignores false grandchildren .Where(c => c.CanBeSelected && c.AutoNavGroup == this.SelectedElement?.AutoNavGroup); if (this.SelectedElement?.Root != this.ActiveRoot) { // if we don't have an element selected in this root, navigate to the first one without a group var allowed = children.Where(c => c.AutoNavGroup == null); return backward ? allowed.LastOrDefault() : allowed.FirstOrDefault(); } else { var foundCurr = false; Element lastFound = null; foreach (var child in children) { if (child == this.SelectedElement) { // when going backwards, return the last element found before the current one if (backward) return lastFound; foundCurr = true; } else { // when going forwards, return the element after the current one if (!backward && foundCurr) return child; } lastFound = child; } return null; } } /// /// Returns the next element that should be selected during gamepad navigation, based on the that we're looking for elements in. /// /// The direction that we're looking for next elements in /// The first element found in that area protected virtual Element GetGamepadNextElement(Direction2 direction) { if (this.ActiveRoot == null) return null; var children = this.ActiveRoot.Element.GetChildren(c => !c.IsHidden, true, true).Concat(Enumerable.Repeat(this.ActiveRoot.Element, 1)) // we can't add these checks to GetChildren because it ignores false grandchildren .Where(c => c.CanBeSelected && c.AutoNavGroup == this.SelectedElement?.AutoNavGroup); if (this.SelectedElement?.Root != this.ActiveRoot) { // if we don't have an element selected in this root, navigate to the first one without a group return children.FirstOrDefault(c => c.AutoNavGroup == null); } else { Element closest = null; float closestPriority = 0; foreach (var child in children) { if (child == this.SelectedElement) continue; var offset = child.Area.Center - this.SelectedElement.Area.Center; var angle = Math.Abs(MathHelper.WrapAngle(direction.Angle() - (float) Math.Atan2(offset.Y, offset.X))); if (angle >= MathHelper.PiOver2 - Element.Epsilon) continue; var distSq = child.Area.DistanceSquared(this.SelectedElement.Area); // both distance and angle play a role in a destination button's priority, so we combine them var priority = (distSq + 1) * (angle / MathHelper.PiOver2 + 1); if (closest == null || priority < closestPriority) { closest = child; closestPriority = priority; } } return closest; } } private bool HandleGamepadNextElement(Direction2 dir) { this.IsAutoNavMode = true; var next = this.GetGamepadNextElement(dir); if (this.SelectedElement != null) next = this.SelectedElement.GetGamepadNextElement(dir, next); if (next != null) { this.SelectElement(this.ActiveRoot, next); return true; } return false; } } }