// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osuTK; using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { /// /// A component which outlines items and handles movement of selections. /// public abstract class SelectionHandler : CompositeDrawable, IKeyBindingHandler, IKeyBindingHandler, IHasContextMenu { /// /// The currently selected blueprints. /// Should be used when operations are dealing directly with the visible blueprints. /// For more general selection operations, use instead. /// public IReadOnlyList> SelectedBlueprints => selectedBlueprints; /// /// The currently selected items. /// public readonly BindableList SelectedItems = new BindableList(); private readonly List> selectedBlueprints; protected SelectionBox SelectionBox { get; private set; } [Resolved(CanBeNull = true)] protected IEditorChangeHandler ChangeHandler { get; private set; } protected SelectionHandler() { selectedBlueprints = new List>(); RelativeSizeAxes = Axes.Both; AlwaysPresent = true; } [BackgroundDependencyLoader] private void load() { InternalChild = SelectionBox = CreateSelectionBox(); SelectedItems.CollectionChanged += (sender, args) => { Scheduler.AddOnce(updateVisibility); }; } public SelectionBox CreateSelectionBox() => new SelectionBox { OperationStarted = OnOperationBegan, OperationEnded = OnOperationEnded, OnRotation = HandleRotation, OnScale = HandleScale, OnFlip = HandleFlip, OnReverse = HandleReverse, }; /// /// Fired when a drag operation ends from the selection box. /// protected virtual void OnOperationBegan() { ChangeHandler?.BeginChange(); } /// /// Fired when a drag operation begins from the selection box. /// protected virtual void OnOperationEnded() { ChangeHandler?.EndChange(); } #region User Input Handling /// /// Positional input must be received outside the container's bounds, /// in order to handle blueprints which are partially offscreen. /// /// /// public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; /// /// Handles the selected items being moved. /// /// /// Just returning true is enough to allow default movement to take place. /// Custom implementation is only required if other attributes are to be considered, like changing columns. /// /// The move event. /// /// Whether any items could be moved. /// public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => false; /// /// Handles the selected items being rotated. /// /// The delta angle to apply to the selection. /// Whether any items could be rotated. public virtual bool HandleRotation(float angle) => false; /// /// Handles the selected items being scaled. /// /// The delta scale to apply, in local coordinates. /// The point of reference where the scale is originating from. /// Whether any items could be scaled. public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false; /// /// Handles the selected items being flipped. /// /// The direction to flip. /// Whether the flip operation should be global to the playfield's origin or local to the selected pattern. /// Whether any items could be flipped. public virtual bool HandleFlip(Direction direction, bool flipOverOrigin) => false; /// /// Handles the selected items being reversed pattern-wise. /// /// Whether any items could be reversed. public virtual bool HandleReverse() => false; public virtual bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) return false; switch (e.Action) { case GlobalAction.EditorFlipHorizontally: return HandleFlip(Direction.Horizontal, true); case GlobalAction.EditorFlipVertically: return HandleFlip(Direction.Vertical, true); } return false; } public void OnReleased(KeyBindingReleaseEvent e) { } public bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) { case PlatformAction.Delete: DeleteSelected(); return true; } return false; } public void OnReleased(KeyBindingReleaseEvent e) { } #endregion #region Selection Handling /// /// Bind an action to deselect all selected blueprints. /// internal Action DeselectAll { private get; set; } /// /// Handle a blueprint becoming selected. /// /// The blueprint. internal virtual void HandleSelected(SelectionBlueprint blueprint) { // there are potentially multiple SelectionHandlers active, but we only want to add items to the selected list once. if (!SelectedItems.Contains(blueprint.Item)) SelectedItems.Add(blueprint.Item); selectedBlueprints.Add(blueprint); } /// /// Handle a blueprint becoming deselected. /// /// The blueprint. internal virtual void HandleDeselected(SelectionBlueprint blueprint) { SelectedItems.Remove(blueprint.Item); selectedBlueprints.Remove(blueprint); } /// /// Handle a blueprint requesting selection. /// /// The blueprint. /// The mouse event responsible for selection. /// Whether a selection was performed. internal virtual bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { if (e.ShiftPressed && e.Button == MouseButton.Right) { handleQuickDeletion(blueprint); return true; } // while holding control, we only want to add to selection, not replace an existing selection. if (e.ControlPressed && e.Button == MouseButton.Left && !blueprint.IsSelected) { blueprint.ToggleSelection(); return true; } return ensureSelected(blueprint); } /// /// Handle a blueprint requesting selection. /// /// The blueprint. /// The mouse event responsible for deselection. /// Whether a deselection was performed. internal bool MouseUpSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { if (blueprint.IsSelected) { blueprint.ToggleSelection(); return true; } return false; } private void handleQuickDeletion(SelectionBlueprint blueprint) { if (blueprint.HandleQuickDeletion()) return; if (!blueprint.IsSelected) DeleteItems(new[] { blueprint.Item }); else DeleteSelected(); } /// /// Given a selection target and a function of truth, retrieve the correct ternary state for display. /// protected static TernaryState GetStateFromSelection(IEnumerable selection, Func func) { if (selection.Any(func)) return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate; return TernaryState.False; } /// /// Called whenever the deletion of items has been requested. /// /// The items to be deleted. protected abstract void DeleteItems(IEnumerable items); /// /// Ensure the blueprint is in a selected state. /// /// The blueprint to select. /// Whether selection state was changed. private bool ensureSelected(SelectionBlueprint blueprint) { if (blueprint.IsSelected) return false; DeselectAll?.Invoke(); blueprint.Select(); return true; } protected void DeleteSelected() { DeleteItems(selectedBlueprints.Select(b => b.Item)); } #endregion #region Outline Display /// /// Updates whether this is visible. /// private void updateVisibility() { int count = SelectedItems.Count; SelectionBox.Text = count > 0 ? count.ToString() : string.Empty; SelectionBox.FadeTo(count > 0 ? 1 : 0); OnSelectionChanged(); } /// /// Triggered whenever the set of selected items changes. /// Should update the selection box's state to match supported operations. /// protected virtual void OnSelectionChanged() { } protected override void Update() { base.Update(); if (selectedBlueprints.Count == 0) return; // Move the rectangle to cover the items RectangleF selectionRect = ToLocalSpace(selectedBlueprints[0].SelectionQuad).AABBFloat; for (int i = 1; i < selectedBlueprints.Count; i++) selectionRect = RectangleF.Union(selectionRect, ToLocalSpace(selectedBlueprints[i].SelectionQuad).AABBFloat); selectionRect = selectionRect.Inflate(5f); SelectionBox.Position = selectionRect.Location; SelectionBox.Size = selectionRect.Size; } #endregion #region Context Menu public MenuItem[] ContextMenuItems { get { if (!SelectedBlueprints.Any(b => b.IsHovered)) return Array.Empty(); var items = new List(); items.AddRange(GetContextMenuItemsForSelection(SelectedBlueprints)); if (SelectedBlueprints.Count == 1) items.AddRange(SelectedBlueprints[0].ContextMenuItems); items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, DeleteSelected)); return items.ToArray(); } } /// /// Provide context menu items relevant to current selection. Calling base is not required. /// /// The current selection. /// The relevant menu items. protected virtual IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) => Enumerable.Empty(); #endregion #region Helper Methods /// /// Rotate a point around an arbitrary origin. /// /// The point. /// The centre origin to rotate around. /// The angle to rotate (in degrees). protected static Vector2 RotatePointAroundOrigin(Vector2 point, Vector2 origin, float angle) { angle = -angle; point.X -= origin.X; point.Y -= origin.Y; Vector2 ret; ret.X = point.X * MathF.Cos(MathUtils.DegreesToRadians(angle)) + point.Y * MathF.Sin(MathUtils.DegreesToRadians(angle)); ret.Y = point.X * -MathF.Sin(MathUtils.DegreesToRadians(angle)) + point.Y * MathF.Cos(MathUtils.DegreesToRadians(angle)); ret.X += origin.X; ret.Y += origin.Y; return ret; } /// /// Given a flip direction, a surrounding quad for all selected objects, and a position, /// will return the flipped position in screen space coordinates. /// protected static Vector2 GetFlippedPosition(Direction direction, Quad quad, Vector2 position) { var centre = quad.Centre; switch (direction) { case Direction.Horizontal: position.X = centre.X - (position.X - centre.X); break; case Direction.Vertical: position.Y = centre.Y - (position.Y - centre.Y); break; } return position; } /// /// Given a scale vector, a surrounding quad for all selected objects, and a position, /// will return the scaled position in screen space coordinates. /// protected static Vector2 GetScaledPosition(Anchor reference, Vector2 scale, Quad selectionQuad, Vector2 position) { // adjust the direction of scale depending on which side the user is dragging. float xOffset = ((reference & Anchor.x0) > 0) ? -scale.X : 0; float yOffset = ((reference & Anchor.y0) > 0) ? -scale.Y : 0; // guard against no-ops and NaN. if (scale.X != 0 && selectionQuad.Width > 0) position.X = selectionQuad.TopLeft.X + xOffset + (position.X - selectionQuad.TopLeft.X) / selectionQuad.Width * (selectionQuad.Width + scale.X); if (scale.Y != 0 && selectionQuad.Height > 0) position.Y = selectionQuad.TopLeft.Y + yOffset + (position.Y - selectionQuad.TopLeft.Y) / selectionQuad.Height * (selectionQuad.Height + scale.Y); return position; } /// /// Returns a quad surrounding the provided points. /// /// The points to calculate a quad for. protected static Quad GetSurroundingQuad(IEnumerable points) { if (!points.Any()) return new Quad(); Vector2 minPosition = new Vector2(float.MaxValue, float.MaxValue); Vector2 maxPosition = new Vector2(float.MinValue, float.MinValue); // Go through all hitobjects to make sure they would remain in the bounds of the editor after movement, before any movement is attempted foreach (var p in points) { minPosition = Vector2.ComponentMin(minPosition, p); maxPosition = Vector2.ComponentMax(maxPosition, p); } Vector2 size = maxPosition - minPosition; return new Quad(minPosition.X, minPosition.Y, size.X, size.Y); } #endregion } }