// 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.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Overlays.Mods.Input; using osu.Game.Rulesets.Mods; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Mods { public partial class ModColumn : ModSelectColumn { public readonly ModType ModType; private IReadOnlyList availableMods = Array.Empty(); /// /// Sets the list of mods to show in this column. /// public IReadOnlyList AvailableMods { get => availableMods; set { Debug.Assert(value.All(mod => mod.Mod.Type == ModType)); availableMods = value; foreach (var mod in availableMods) { mod.Active.BindValueChanged(_ => updateState()); mod.MatchingTextFilter.BindValueChanged(_ => updateState()); mod.ValidForSelection.BindValueChanged(_ => updateState()); } updateState(); if (IsLoaded) asyncLoadPanels(); } } protected virtual ModPanel CreateModPanel(ModState mod) => new ModPanel(mod); private readonly bool allowIncompatibleSelection; private readonly ToggleAllCheckbox? toggleAllCheckbox; private Bindable hotkeyStyle = null!; private IModHotkeyHandler hotkeyHandler = null!; private Task? latestLoadTask; private ModPanel[]? latestLoadedPanels; internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true && allPanelsLoaded; private bool? wasPresent; private bool allPanelsLoaded { get { if (latestLoadedPanels == null) return false; foreach (var panel in latestLoadedPanels) { if (panel.Parent == null) return false; } return true; } } public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; public ModColumn(ModType modType, bool allowIncompatibleSelection) { ModType = modType; this.allowIncompatibleSelection = allowIncompatibleSelection; HeaderText = ModType.Humanize(LetterCasing.Title); if (allowIncompatibleSelection) { ControlContainer.Height = 35; ControlContainer.Add(toggleAllCheckbox = new ToggleAllCheckbox(this) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Scale = new Vector2(0.8f), RelativeSizeAxes = Axes.X, Shear = new Vector2(-OsuGame.SHEAR, 0) }); ItemsFlow.Padding = new MarginPadding { Top = 0, Bottom = 7, Horizontal = 7 }; } } [BackgroundDependencyLoader] private void load(OsuColour colours, OsuConfigManager configManager) { AccentColour = colours.ForModType(ModType); if (toggleAllCheckbox != null) { toggleAllCheckbox.AccentColour = AccentColour; toggleAllCheckbox.AccentHoverColour = AccentColour.Lighten(0.3f); } hotkeyStyle = configManager.GetBindable(OsuSetting.ModSelectHotkeyStyle); } protected override void LoadComplete() { base.LoadComplete(); toggleAllCheckbox?.Current.BindValueChanged(_ => updateToggleAllText(), true); hotkeyStyle.BindValueChanged(val => hotkeyHandler = createHotkeyHandler(val.NewValue), true); asyncLoadPanels(); } private void updateToggleAllText() { Debug.Assert(toggleAllCheckbox != null); toggleAllCheckbox.LabelText = toggleAllCheckbox.Current.Value ? CommonStrings.DeselectAll : CommonStrings.SelectAll; } private CancellationTokenSource? cancellationTokenSource; private void asyncLoadPanels() { cancellationTokenSource?.Cancel(); var panels = availableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = Vector2.Zero)).ToArray(); latestLoadedPanels = panels; latestLoadTask = LoadComponentsAsync(panels, loaded => { ItemsFlow.ChildrenEnumerable = loaded; updateState(); }, (cancellationTokenSource = new CancellationTokenSource()).Token); } private void updateState() { Alpha = availableMods.All(mod => !mod.Visible) ? 0 : 1; if (toggleAllCheckbox != null && !SelectionAnimationRunning) { bool anyPanelsVisible = availableMods.Any(panel => panel.Visible); toggleAllCheckbox.Alpha = anyPanelsVisible ? 1 : 0; // checking `anyPanelsVisible` is important since `.All()` returns `true` for empty enumerables. if (anyPanelsVisible) toggleAllCheckbox.Current.Value = availableMods.Where(panel => panel.Visible).All(panel => panel.Active.Value); } } #region Bulk select / deselect private const double initial_multiple_selection_delay = 120; private double selectionDelay = initial_multiple_selection_delay; private double lastSelection; private readonly Queue pendingSelectionOperations = new Queue(); internal bool SelectionAnimationRunning => pendingSelectionOperations.Count > 0; protected override void Update() { base.Update(); // we override `IsPresent` to include the scheduler's pending task state to make async loads work correctly when columns are masked away // (see description of https://github.com/ppy/osu/pull/19783). // however, because of that we must also ensure that we signal correct invalidations (https://github.com/ppy/osu-framework/issues/5129). // failing to do so causes columns to be stuck in "present" mode despite actually not being present themselves. // this works because `Update()` will always run after a scheduler update, which is what causes the presence state change responsible for the failure. if (wasPresent != null && wasPresent != IsPresent) Invalidate(Invalidation.Presence); wasPresent = IsPresent; if (selectionDelay == initial_multiple_selection_delay || Time.Current - lastSelection >= selectionDelay) { if (pendingSelectionOperations.TryDequeue(out var dequeuedAction)) { dequeuedAction(); // each time we play an animation, we decrease the time until the next animation (to ramp the visual and audible elements). selectionDelay = Math.Max(ModSelectPanel.SAMPLE_PLAYBACK_DELAY, selectionDelay * 0.8f); lastSelection = Time.Current; } else { // reset the selection delay after all animations have been completed. // this will cause the next action to be immediately performed. selectionDelay = initial_multiple_selection_delay; } } } /// /// Selects all mods. /// public void SelectAll() { pendingSelectionOperations.Clear(); foreach (var button in availableMods.Where(b => !b.Active.Value && b.Visible)) pendingSelectionOperations.Enqueue(() => button.Active.Value = true); } /// /// Deselects all mods. /// public void DeselectAll() { pendingSelectionOperations.Clear(); foreach (var button in availableMods.Where(b => b.Active.Value)) { if (!button.Visible) button.Active.Value = false; else pendingSelectionOperations.Enqueue(() => button.Active.Value = false); } } /// /// Run any delayed selections (due to animation) immediately to leave mods in a good (final) state. /// public void FlushPendingSelections() { while (pendingSelectionOperations.TryDequeue(out var dequeuedAction)) dequeuedAction(); } private partial class ToggleAllCheckbox : OsuCheckbox { private Color4 accentColour; public Color4 AccentColour { get => accentColour; set { accentColour = value; updateState(); } } private Color4 accentHoverColour; public Color4 AccentHoverColour { get => accentHoverColour; set { accentHoverColour = value; updateState(); } } private readonly ModColumn column; public ToggleAllCheckbox(ModColumn column) : base(false) { this.column = column; } protected override void ApplyLabelParameters(SpriteText text) { base.ApplyLabelParameters(text); text.Font = text.Font.With(weight: FontWeight.SemiBold); } [BackgroundDependencyLoader] private void load() { updateState(); } private void updateState() { Nub.AccentColour = AccentColour; Nub.GlowingAccentColour = AccentHoverColour; Nub.GlowColour = AccentHoverColour.Opacity(0.2f); } protected override void OnUserChange(bool value) { if (value) column.SelectAll(); else column.DeselectAll(); } } #endregion #region Keyboard selection support /// /// Creates an appropriate for this column's and /// the supplied . /// private IModHotkeyHandler createHotkeyHandler(ModSelectHotkeyStyle hotkeyStyle) { switch (ModType) { case ModType.DifficultyReduction: case ModType.DifficultyIncrease: case ModType.Automation: return hotkeyStyle == ModSelectHotkeyStyle.Sequential ? SequentialModHotkeyHandler.Create(ModType) : new ClassicModHotkeyHandler(allowIncompatibleSelection); default: return new NoopModHotkeyHandler(); } } protected override bool OnKeyDown(KeyDownEvent e) { if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.Repeat) return false; return hotkeyHandler.HandleHotkeyPressed(e, availableMods); } #endregion } }