// 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.Generic; using System.Linq; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Screens.Edit.Compose.Components { public partial class EditorSelectionHandler : SelectionHandler { /// /// A special bank name that is only used in the editor UI. /// When selected and in placement mode, the bank of the last hit object will always be used. /// public const string HIT_BANK_AUTO = "auto"; [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } = null!; [BackgroundDependencyLoader] private void load() { createStateBindables(); // bring in updates from selection changes EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates); SelectedItems.CollectionChanged += (_, _) => Scheduler.AddOnce(UpdateTernaryStates); } protected override void DeleteItems(IEnumerable items) => EditorBeatmap.RemoveRange(items); #region Selection State /// /// The state of "new combo" for all selected hitobjects. /// public readonly Bindable SelectionNewComboState = new Bindable(); /// /// The state of each sample type for all selected hitobjects. Keys match with constant specifications. /// public readonly Dictionary> SelectionSampleStates = new Dictionary>(); /// /// The state of each sample bank type for all selected hitobjects. /// public readonly Dictionary> SelectionBankStates = new Dictionary>(); /// /// The state of each sample addition bank type for all selected hitobjects. /// public readonly Dictionary> SelectionAdditionBankStates = new Dictionary>(); /// /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) /// private void createStateBindables() { foreach (string bankName in HitSampleInfo.AllBanks.Prepend(HIT_BANK_AUTO)) { var bindable = new Bindable { Description = bankName.Titleize() }; bindable.ValueChanged += state => { switch (state.NewValue) { case TernaryState.False: if (SelectedItems.Count == 0) { // Ensure that if this is the last selected bank, it should remain selected. if (SelectionBankStates.Values.All(b => b.Value == TernaryState.False)) bindable.Value = TernaryState.True; } else { // Auto should never apply when there is a selection made. // This is also required to stop a bindable feedback loop when a HitObject has zero samples (and LINQ `All` below becomes true). if (bankName == HIT_BANK_AUTO) break; // Never remove a sample bank. // These are basically radio buttons, not toggles. if (SelectedItems.All(h => h.Samples.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName))) bindable.Value = TernaryState.True; } break; case TernaryState.True: if (SelectedItems.Count == 0) { // Ensure the user can't stack multiple bank selections when there's no hitobject selection. // Note that in normal scenarios this is sorted out by the feedback from applying the bank to the selected objects. foreach (var other in SelectionBankStates.Values) { if (other != bindable) other.Value = TernaryState.False; } } else { // Auto should just not apply if there's a selection already made. // Maybe we could make it a disabled button in the future, but right now the editor buttons don't support disabled state. if (bankName == HIT_BANK_AUTO) { bindable.Value = TernaryState.False; break; } SetSampleBank(bankName); } break; } }; SelectionBankStates[bankName] = bindable; } foreach (string bankName in HitSampleInfo.AllBanks.Prepend(HIT_BANK_AUTO)) { var bindable = new Bindable { Description = bankName.Titleize() }; bindable.ValueChanged += state => { switch (state.NewValue) { case TernaryState.False: if (SelectedItems.Count == 0) { // Ensure that if this is the last selected bank, it should remain selected. if (SelectionAdditionBankStates.Values.All(b => b.Value == TernaryState.False)) bindable.Value = TernaryState.True; } // Auto should never apply when there is a selection made. // Completely empty selections should be allowed in the case that none of the selected objects have any addition samples. break; case TernaryState.True: if (SelectedItems.Count == 0) { // Ensure the user can't stack multiple bank selections when there's no hitobject selection. // Note that in normal scenarios this is sorted out by the feedback from applying the bank to the selected objects. foreach (var other in SelectionAdditionBankStates.Values) { if (other != bindable) other.Value = TernaryState.False; } } else { // Auto should just not apply if there's a selection already made. // Maybe we could make it a disabled button in the future, but right now the editor buttons don't support disabled state. if (bankName == HIT_BANK_AUTO) { bindable.Value = TernaryState.False; break; } // If none of the selected objects have any addition samples, we should not apply the bank. if (SelectedItems.All(h => h.Samples.All(o => o.Name == HitSampleInfo.HIT_NORMAL))) { bindable.Value = TernaryState.False; break; } SetSampleAdditionBank(bankName); } break; } }; SelectionAdditionBankStates[bankName] = bindable; } // start with normal selected. SelectionBankStates[SampleControlPoint.DEFAULT_BANK].Value = TernaryState.True; SelectionAdditionBankStates[SampleControlPoint.DEFAULT_BANK].Value = TernaryState.True; foreach (string sampleName in HitSampleInfo.AllAdditions) { var bindable = new Bindable { Description = sampleName.Replace("hit", string.Empty).Titleize() }; bindable.ValueChanged += state => { switch (state.NewValue) { case TernaryState.False: RemoveHitSample(sampleName); break; case TernaryState.True: AddHitSample(sampleName); break; } }; SelectionSampleStates[sampleName] = bindable; } // new combo SelectionNewComboState.ValueChanged += state => { switch (state.NewValue) { case TernaryState.False: SetNewCombo(false); break; case TernaryState.True: SetNewCombo(true); break; } }; } /// /// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated). /// protected virtual void UpdateTernaryStates() { SelectionNewComboState.Value = GetStateFromSelection(SelectedItems.OfType(), h => h.NewCombo); var samplesInSelection = SelectedItems.SelectMany(enumerateAllSamples).ToArray(); foreach ((string sampleName, var bindable) in SelectionSampleStates) { bindable.Value = GetStateFromSelection(samplesInSelection, h => h.Any(s => s.Name == sampleName)); } foreach ((string bankName, var bindable) in SelectionBankStates) { bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name == HitSampleInfo.HIT_NORMAL), h => h.Bank == bankName); } foreach ((string bankName, var bindable) in SelectionAdditionBankStates) { bindable.Value = GetStateFromSelection(samplesInSelection.SelectMany(s => s).Where(o => o.Name != HitSampleInfo.HIT_NORMAL), h => h.Bank == bankName); } IEnumerable> enumerateAllSamples(HitObject hitObject) { yield return hitObject.Samples; if (hitObject is IHasRepeats withRepeats) { foreach (var node in withRepeats.NodeSamples) yield return node; } } } #endregion #region Ternary state changes /// /// Sets the sample bank for all selected s. /// /// The name of the sample bank. public void SetSampleBank(string bankName) { bool hasRelevantBank(HitObject hitObject) { bool result = hitObject.Samples.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName); if (hitObject is IHasRepeats hasRepeats) { foreach (var node in hasRepeats.NodeSamples) result &= node.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName); } return result; } if (SelectedItems.All(hasRelevantBank)) return; EditorBeatmap.PerformOnSelection(h => { if (h.Samples.Where(o => o.Name == HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName)) return; h.Samples = h.Samples.Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); if (h is IHasRepeats hasRepeats) { for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name == HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); } EditorBeatmap.Update(h); }); } /// /// Sets the sample addition bank for all selected s. /// /// The name of the sample bank. public void SetSampleAdditionBank(string bankName) { bool hasRelevantBank(HitObject hitObject) { bool result = hitObject.Samples.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName); if (hitObject is IHasRepeats hasRepeats) { foreach (var node in hasRepeats.NodeSamples) result &= node.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName); } return result; } if (SelectedItems.All(hasRelevantBank)) return; EditorBeatmap.PerformOnSelection(h => { if (h.Samples.Where(o => o.Name != HitSampleInfo.HIT_NORMAL).All(s => s.Bank == bankName)) return; h.Samples = h.Samples.Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); if (h is IHasRepeats hasRepeats) { for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Select(s => s.Name != HitSampleInfo.HIT_NORMAL ? s.With(newBank: bankName) : s).ToList(); } EditorBeatmap.Update(h); }); } private bool hasRelevantSample(HitObject hitObject, string sampleName) { bool result = hitObject.Samples.Any(s => s.Name == sampleName); if (hitObject is IHasRepeats hasRepeats) { foreach (var node in hasRepeats.NodeSamples) result &= node.Any(s => s.Name == sampleName); } return result; } /// /// Adds a hit sample to all selected s. /// /// The name of the hit sample. public void AddHitSample(string sampleName) { if (SelectedItems.All(h => hasRelevantSample(h, sampleName))) return; EditorBeatmap.PerformOnSelection(h => { // Make sure there isn't already an existing sample if (h.Samples.Any(s => s.Name == sampleName)) return; h.Samples.Add(h.CreateHitSampleInfo(sampleName)); if (h is IHasRepeats hasRepeats) { foreach (var node in hasRepeats.NodeSamples) { if (node.Any(s => s.Name == sampleName)) continue; var hitSample = h.CreateHitSampleInfo(sampleName); string? existingAdditionBank = node.FirstOrDefault(s => s.Name != HitSampleInfo.HIT_NORMAL)?.Bank; if (existingAdditionBank != null) hitSample = hitSample.With(newBank: existingAdditionBank); node.Add(hitSample); } } EditorBeatmap.Update(h); }); } /// /// Removes a hit sample from all selected s. /// /// The name of the hit sample. public void RemoveHitSample(string sampleName) { if (SelectedItems.All(h => !hasRelevantSample(h, sampleName))) return; EditorBeatmap.PerformOnSelection(h => { h.SamplesBindable.RemoveAll(s => s.Name == sampleName); if (h is IHasRepeats hasRepeats) { for (int i = 0; i < hasRepeats.NodeSamples.Count; ++i) hasRepeats.NodeSamples[i] = hasRepeats.NodeSamples[i].Where(s => s.Name != sampleName).ToList(); } EditorBeatmap.Update(h); }); } /// /// Set the new combo state of all selected s. /// /// Whether to set or unset. /// Throws if any selected object doesn't implement public void SetNewCombo(bool state) { if (SelectedItems.OfType().All(h => h.NewCombo == state)) return; EditorBeatmap.PerformOnSelection(h => { var comboInfo = h as IHasComboInformation; if (comboInfo == null || comboInfo.NewCombo == state) return; comboInfo.NewCombo = state; EditorBeatmap.Update(h); }); } #endregion #region Context Menu /// /// Provide context menu items relevant to current selection. Calling base is not required. /// /// The current selection. /// The relevant menu items. protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { if (SelectedBlueprints.All(b => b.Item is IHasComboInformation)) { yield return new TernaryStateToggleMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }; } yield return new OsuMenuItem("Sample") { Items = SelectionSampleStates.Select(kvp => new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() }; yield return new OsuMenuItem("Bank") { Items = SelectionBankStates.Select(kvp => new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() }; yield return new OsuMenuItem("Addition bank") { Items = SelectionAdditionBankStates.Select(kvp => new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() }; } #endregion } }