// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osuTK; using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { /// /// A container which provides a "blueprint" display of hitobjects. /// Includes selection and manipulation support via a . /// public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler { protected DragBox DragBox { get; private set; } protected Container SelectionBlueprints { get; private set; } private SelectionHandler selectionHandler; [Resolved(CanBeNull = true)] private IEditorChangeHandler changeHandler { get; set; } [Resolved] private EditorClock editorClock { get; set; } [Resolved] private EditorBeatmap beatmap { get; set; } private readonly BindableList selectedHitObjects = new BindableList(); [Resolved(canBeNull: true)] private IPositionSnapProvider snapProvider { get; set; } protected BlueprintContainer() { RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] private void load() { selectionHandler = CreateSelectionHandler(); selectionHandler.DeselectAll = deselectAll; AddRangeInternal(new[] { DragBox = CreateDragBox(select), selectionHandler, SelectionBlueprints = CreateSelectionBlueprintContainer(), DragBox.CreateProxy().With(p => p.Depth = float.MinValue) }); foreach (var obj in beatmap.HitObjects) AddBlueprintFor(obj); selectedHitObjects.BindTo(beatmap.SelectedHitObjects); selectedHitObjects.CollectionChanged += (selectedObjects, args) => { switch (args.Action) { case NotifyCollectionChangedAction.Add: foreach (var o in args.NewItems) SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Select(); break; case NotifyCollectionChangedAction.Remove: foreach (var o in args.OldItems) SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Deselect(); break; } }; } protected override void LoadComplete() { base.LoadComplete(); beatmap.HitObjectAdded += AddBlueprintFor; beatmap.HitObjectRemoved += removeBlueprintFor; } protected virtual Container CreateSelectionBlueprintContainer() => new Container { RelativeSizeAxes = Axes.Both }; /// /// Creates a which outlines s and handles movement of selections. /// protected virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler(); /// /// Creates a for a specific . /// /// The to create the overlay for. protected virtual SelectionBlueprint CreateBlueprintFor(HitObject hitObject) => null; protected virtual DragBox CreateDragBox(Action performSelect) => new DragBox(performSelect); protected override bool OnMouseDown(MouseDownEvent e) { beginClickSelection(e); prepareSelectionMovement(); return e.Button == MouseButton.Left; } protected override bool OnClick(ClickEvent e) { if (e.Button == MouseButton.Right) return false; // Deselection should only occur if no selected blueprints are hovered // A special case for when a blueprint was selected via this click is added since OnClick() may occur outside the hitobject and should not trigger deselection if (endClickSelection() || selectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) return true; deselectAll(); return true; } protected override bool OnDoubleClick(DoubleClickEvent e) { if (e.Button == MouseButton.Right) return false; SelectionBlueprint clickedBlueprint = selectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered); if (clickedBlueprint == null) return false; editorClock?.SeekTo(clickedBlueprint.HitObject.StartTime); return true; } protected override void OnMouseUp(MouseUpEvent e) { // Special case for when a drag happened instead of a click Schedule(() => endClickSelection()); finishSelectionMovement(); } protected override bool OnDragStart(DragStartEvent e) { if (e.Button == MouseButton.Right) return false; if (movementBlueprint != null) { isDraggingBlueprint = true; changeHandler?.BeginChange(); return true; } if (DragBox.HandleDrag(e)) { DragBox.Show(); return true; } return false; } protected override void OnDrag(DragEvent e) { if (e.Button == MouseButton.Right) return; if (DragBox.State == Visibility.Visible) DragBox.HandleDrag(e); moveCurrentSelection(e); } protected override void OnDragEnd(DragEndEvent e) { if (e.Button == MouseButton.Right) return; if (isDraggingBlueprint) { changeHandler?.EndChange(); isDraggingBlueprint = false; } if (DragBox.State == Visibility.Visible) { DragBox.Hide(); selectionHandler.UpdateVisibility(); } } protected override bool OnKeyDown(KeyDownEvent e) { switch (e.Key) { case Key.Escape: if (!selectionHandler.SelectedBlueprints.Any()) return false; deselectAll(); return true; } return false; } public bool OnPressed(PlatformAction action) { switch (action.ActionType) { case PlatformActionType.SelectAll: selectAll(); return true; } return false; } public void OnReleased(PlatformAction action) { } #region Blueprint Addition/Removal private void removeBlueprintFor(HitObject hitObject) { var blueprint = SelectionBlueprints.SingleOrDefault(m => m.HitObject == hitObject); if (blueprint == null) return; blueprint.Deselect(); blueprint.Selected -= onBlueprintSelected; blueprint.Deselected -= onBlueprintDeselected; SelectionBlueprints.Remove(blueprint); } protected virtual void AddBlueprintFor(HitObject hitObject) { var blueprint = CreateBlueprintFor(hitObject); if (blueprint == null) return; blueprint.Selected += onBlueprintSelected; blueprint.Deselected += onBlueprintDeselected; SelectionBlueprints.Add(blueprint); } #endregion #region Selection /// /// Whether a blueprint was selected by a previous click event. /// private bool clickSelectionBegan; /// /// Attempts to select any hovered blueprints. /// /// The input event that triggered this selection. private void beginClickSelection(MouseButtonEvent e) { Debug.Assert(!clickSelectionBegan); // Deselections are only allowed for control + left clicks bool allowDeselection = e.ControlPressed && e.Button == MouseButton.Left; // Todo: This is probably incorrectly disallowing multiple selections on stacked objects if (!allowDeselection && selectionHandler.SelectedBlueprints.Any(s => s.IsHovered)) return; foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren) { if (blueprint.IsHovered) { selectionHandler.HandleSelectionRequested(blueprint, e.CurrentState); clickSelectionBegan = true; break; } } } /// /// Finishes the current blueprint selection. /// /// Whether a click selection was active. private bool endClickSelection() { if (!clickSelectionBegan) return false; clickSelectionBegan = false; return true; } /// /// Select all masks in a given rectangle selection area. /// /// The rectangle to perform a selection on in screen-space coordinates. private void select(RectangleF rect) { foreach (var blueprint in SelectionBlueprints) { if (blueprint.IsAlive && blueprint.IsPresent && rect.Contains(blueprint.ScreenSpaceSelectionPoint)) blueprint.Select(); else blueprint.Deselect(); } } /// /// Selects all s. /// private void selectAll() { SelectionBlueprints.ToList().ForEach(m => m.Select()); selectionHandler.UpdateVisibility(); } /// /// Deselects all selected s. /// private void deselectAll() => selectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect()); private void onBlueprintSelected(SelectionBlueprint blueprint) { selectionHandler.HandleSelected(blueprint); SelectionBlueprints.ChangeChildDepth(blueprint, 1); beatmap.SelectedHitObjects.Add(blueprint.HitObject); } private void onBlueprintDeselected(SelectionBlueprint blueprint) { selectionHandler.HandleDeselected(blueprint); SelectionBlueprints.ChangeChildDepth(blueprint, 0); beatmap.SelectedHitObjects.Remove(blueprint.HitObject); } #endregion #region Selection Movement private Vector2? movementBlueprintOriginalPosition; private SelectionBlueprint movementBlueprint; private bool isDraggingBlueprint; /// /// Attempts to begin the movement of any selected blueprints. /// private void prepareSelectionMovement() { if (!selectionHandler.SelectedBlueprints.Any()) return; // Any selected blueprint that is hovered can begin the movement of the group, however only the earliest hitobject is used for movement // A special case is added for when a click selection occurred before the drag if (!clickSelectionBegan && !selectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) return; // Movement is tracked from the blueprint of the earliest hitobject, since it only makes sense to distance snap from that hitobject movementBlueprint = selectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).First(); movementBlueprintOriginalPosition = movementBlueprint.ScreenSpaceSelectionPoint; // todo: unsure if correct } /// /// Moves the current selected blueprints. /// /// The defining the movement event. /// Whether a movement was active. private bool moveCurrentSelection(DragEvent e) { if (movementBlueprint == null) return false; Debug.Assert(movementBlueprintOriginalPosition != null); HitObject draggedObject = movementBlueprint.HitObject; // The final movement position, relative to movementBlueprintOriginalPosition. Vector2 movePosition = movementBlueprintOriginalPosition.Value + e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; // Retrieve a snapped position. var result = snapProvider.SnapScreenSpacePositionToValidTime(movePosition); // Move the hitobjects. if (!selectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, result.ScreenSpacePosition))) return true; if (result.Time.HasValue) { // Apply the start time at the newly snapped-to position double offset = result.Time.Value - draggedObject.StartTime; foreach (HitObject obj in selectionHandler.SelectedHitObjects) obj.StartTime += offset; } return true; } /// /// Finishes the current movement of selected blueprints. /// /// Whether a movement was active. private bool finishSelectionMovement() { if (movementBlueprint == null) return false; movementBlueprintOriginalPosition = null; movementBlueprint = null; return true; } #endregion protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (beatmap != null) { beatmap.HitObjectAdded -= AddBlueprintFor; beatmap.HitObjectRemoved -= removeBlueprintFor; } } } }