1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-18 20:30:27 +08:00
Files
osu-lazer/osu.Game/Screens/Edit/Editor.cs
T
Bartłomiej Dach b7c695b47c Improve reliability of unapplying speed adjustment on exiting editor
Probably closes https://github.com/ppy/osu/issues/33230.

I say "probably" because I couldn't reproduce this myself using the
scenario provided in the issue but looking at the code involved I can
see why it would happen. Long and short of it is that the speed
adjustment cleanup code was much too reliant on disposal executing
quickly, which as we've learned on several occasions before, cannot be
relied upon.
2025-05-22 09:28:47 +02:00

1643 lines
64 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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 System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Framework.Threading;
using osu.Framework.Timing;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Overlays.OSD;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Screens.Backgrounds;
using osu.Game.Screens.Edit.Components.Menus;
using osu.Game.Screens.Edit.Compose;
using osu.Game.Screens.Edit.Compose.Components.Timeline;
using osu.Game.Screens.Edit.Design;
using osu.Game.Screens.Edit.GameplayTest;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Edit.Submission;
using osu.Game.Screens.Edit.Timing;
using osu.Game.Screens.Edit.Verify;
using osu.Game.Screens.OnlinePlay;
using osu.Game.Users;
using osuTK.Input;
using WebCommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
namespace osu.Game.Screens.Edit
{
[Cached(typeof(IBeatSnapProvider))]
[Cached]
public partial class Editor : OsuScreen, IKeyBindingHandler<GlobalAction>, IKeyBindingHandler<PlatformAction>, IBeatSnapProvider, ISamplePlaybackDisabler, IBeatSyncProvider
{
/// <summary>
/// An offset applied to waveform visuals to align them with expectations.
/// </summary>
/// <remarks>
/// Historically, osu! beatmaps have an assumption of full system latency baked in.
/// This comes from a culmination of stable's platform offset, average hardware playback
/// latency, and users having their universal offsets tweaked to previous beatmaps.
///
/// Coming to this value involved running various tests with existing users / beatmaps.
/// This included both visual and audible comparisons. Ballpark confidence is ≈2 ms.
/// </remarks>
public const float WAVEFORM_VISUAL_OFFSET = 20;
public override float BackgroundParallaxAmount => 0.1f;
public override bool HideOverlaysOnEnter => true;
public override bool DisallowExternalBeatmapRulesetChanges => true;
public override bool? ApplyModTrackAdjustments => false;
protected override bool PlayExitSound => !ExitConfirmed && !switchingDifficulty;
protected bool HasUnsavedChanges
{
get
{
if (!canSave)
return false;
return lastSavedHash != changeHandler?.CurrentStateHash;
}
}
[Resolved]
private BeatmapManager beatmapManager { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
[Resolved(canBeNull: true)]
private IDialogOverlay dialogOverlay { get; set; }
[Resolved(canBeNull: true)]
private INotificationOverlay notifications { get; set; }
[Resolved(canBeNull: true)]
[CanBeNull]
private LoginOverlay loginOverlay { get; set; }
[Resolved]
private RealmAccess realm { get; set; }
public readonly Bindable<EditorScreenMode> Mode = new Bindable<EditorScreenMode>();
public IBindable<bool> SamplePlaybackDisabled => samplePlaybackDisabled;
/// <summary>
/// Ensure all asynchronously loading pieces of the editor are in a good state.
/// This exists here for convenience for tests, not for actual use.
/// Eventually we'd probably want a better way to signal this.
/// </summary>
public bool ReadyForUse
{
get
{
if (!workingBeatmapUpdated)
return false;
if (currentScreen?.IsLoaded != true)
return false;
if (currentScreen is EditorScreenWithTimeline)
return currentScreen.ChildrenOfType<TimelineArea>().FirstOrDefault()?.IsLoaded == true;
return true;
}
}
private bool workingBeatmapUpdated;
private readonly Bindable<bool> samplePlaybackDisabled = new Bindable<bool>();
private bool canSave;
private readonly List<MenuItem> saveRelatedMenuItems = new List<MenuItem>();
/// <summary>
/// Tracks ongoing mutually-exclusive operations related to changing the beatmap
/// (e.g. save, export).
/// </summary>
public OngoingOperationTracker MutationTracker { get; } = new OngoingOperationTracker();
protected bool ExitConfirmed { get; private set; }
private bool switchingDifficulty;
private string lastSavedHash;
private EditorMenuItem discardChangesMenuItem;
private ScreenContainer screenContainer;
[CanBeNull]
private readonly EditorLoader loader;
private EditorScreen currentScreen;
private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor();
private EditorClock clock;
private IBeatmap playableBeatmap;
private EditorBeatmap editorBeatmap;
private BottomBar bottomBar;
[CanBeNull] // Should be non-null once it can support custom rulesets.
private EditorChangeHandler changeHandler;
private DependencyContainer dependencies;
private bool isNewBeatmap;
protected override UserActivity InitialActivity
{
get
{
if (Beatmap.Value.Metadata.Author.OnlineID == api.LocalUser.Value.OnlineID)
return new UserActivity.EditingBeatmap(Beatmap.Value.BeatmapInfo);
return new UserActivity.ModdingBeatmap(Beatmap.Value.BeatmapInfo);
}
}
protected override bool InitialBackButtonVisibility => false;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
=> dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
[Resolved]
private IAPIProvider api { get; set; }
[Cached]
public readonly EditorClipboard Clipboard = new EditorClipboard();
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[Resolved(canBeNull: true)]
private OnScreenDisplay onScreenDisplay { get; set; }
private Bindable<float> editorBackgroundDim;
private Bindable<bool> editorShowStoryboard;
private Bindable<bool> editorHitMarkers;
private Bindable<bool> editorAutoSeekOnPlacement;
private Bindable<bool> editorLimitedDistanceSnap;
private Bindable<bool> editorTimelineShowTimingChanges;
private Bindable<bool> editorTimelineShowBreaks;
private Bindable<bool> editorTimelineShowTicks;
private Bindable<bool> editorContractSidebars;
/// <summary>
/// This controls the opacity of components like the timelines, sidebars, etc.
/// In "composer focus" mode the opacity of the aforementioned components is reduced so that the user can focus on the composer better.
/// </summary>
/// <remarks>
/// The state of this bindable is controlled by <see cref="HitObjectComposer"/> when in <see cref="EditorScreenMode.Compose"/> mode.
/// </remarks>
public Bindable<bool> ComposerFocusMode { get; } = new Bindable<bool>();
[CanBeNull]
public event Action<double> ShowSampleEditPopoverRequested;
public Editor(EditorLoader loader = null)
{
this.loader = loader;
}
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
var loadableBeatmap = Beatmap.Value;
if (loadableBeatmap is DummyWorkingBeatmap)
{
Logger.Log("Editor was loaded without a valid beatmap; creating a new beatmap.");
isNewBeatmap = true;
loadableBeatmap = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value);
// required so we can get the track length in EditorClock.
// this is ONLY safe because the track being provided is a `TrackVirtual` which we don't really care about disposing.
loadableBeatmap.LoadTrack();
// this is a bit haphazard, but guards against setting the lease Beatmap bindable if
// the editor has already been exited.
if (!ValidForPush)
{
beatmapManager.Delete(loadableBeatmap.BeatmapSetInfo);
return;
}
}
try
{
playableBeatmap = loadableBeatmap.GetPlayableBeatmap(loadableBeatmap.BeatmapInfo.Ruleset);
// clone these locally for now to avoid incurring overhead on GetPlayableBeatmap usages.
// eventually we will want to improve how/where this is done as there are issues with *not* cloning it in all cases.
playableBeatmap.ControlPointInfo = playableBeatmap.ControlPointInfo.DeepClone();
}
catch (Exception e)
{
Logger.Error(e, "Could not load beatmap successfully!");
// couldn't load, hard abort!
this.Exit();
return;
}
// Todo: should probably be done at a DrawableRuleset level to share logic with Player.
clock = new EditorClock(playableBeatmap, beatDivisor);
clock.ChangeSource(loadableBeatmap.Track);
dependencies.CacheAs(clock);
AddInternal(clock);
clock.SeekingOrStopped.BindValueChanged(_ => updateSampleDisabledState());
// todo: remove caching of this and consume via editorBeatmap?
dependencies.Cache(beatDivisor);
AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, loadableBeatmap.GetSkin(), loadableBeatmap.BeatmapInfo));
dependencies.CacheAs(editorBeatmap);
editorBeatmap.UpdateInProgress.BindValueChanged(_ => updateSampleDisabledState());
canSave = editorBeatmap.BeatmapInfo.Ruleset.CreateInstance() is ILegacyRuleset;
if (canSave)
{
changeHandler = new BeatmapEditorChangeHandler(editorBeatmap);
dependencies.CacheAs<IEditorChangeHandler>(changeHandler);
}
beatDivisor.SetArbitraryDivisor(editorBeatmap.BeatmapInfo.BeatDivisor);
beatDivisor.BindValueChanged(divisor => editorBeatmap.BeatmapInfo.BeatDivisor = divisor.NewValue);
updateLastSavedHash();
Schedule(() =>
{
// we need to avoid changing the beatmap from an asynchronous load thread. it can potentially cause weirdness including crashes.
// this assumes that nothing during the rest of this load() method is accessing Beatmap.Value (loadableBeatmap should be preferred).
// generally this is quite safe, as the actual load of editor content comes after menuBar.Mode.ValueChanged is fired in its own LoadComplete.
Beatmap.Value = loadableBeatmap;
workingBeatmapUpdated = true;
});
var bookmarkController = new BookmarkController();
AddInternal(bookmarkController);
OsuMenuItem undoMenuItem;
OsuMenuItem redoMenuItem;
editorBackgroundDim = config.GetBindable<float>(OsuSetting.EditorDim);
editorShowStoryboard = config.GetBindable<bool>(OsuSetting.EditorShowStoryboard);
editorHitMarkers = config.GetBindable<bool>(OsuSetting.EditorShowHitMarkers);
editorAutoSeekOnPlacement = config.GetBindable<bool>(OsuSetting.EditorAutoSeekOnPlacement);
editorLimitedDistanceSnap = config.GetBindable<bool>(OsuSetting.EditorLimitedDistanceSnap);
editorTimelineShowTimingChanges = config.GetBindable<bool>(OsuSetting.EditorTimelineShowTimingChanges);
editorTimelineShowBreaks = config.GetBindable<bool>(OsuSetting.EditorTimelineShowBreaks);
editorTimelineShowTicks = config.GetBindable<bool>(OsuSetting.EditorTimelineShowTicks);
editorContractSidebars = config.GetBindable<bool>(OsuSetting.EditorContractSidebars);
// These two settings don't work together. Make them mutually exclusive to let the user know.
editorAutoSeekOnPlacement.BindValueChanged(enabled =>
{
if (enabled.NewValue)
editorLimitedDistanceSnap.Value = false;
});
editorLimitedDistanceSnap.BindValueChanged(enabled =>
{
if (enabled.NewValue)
editorAutoSeekOnPlacement.Value = false;
});
AddInternal(new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Container
{
Name = "Screen container",
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Top = 40, Bottom = 50 },
Child = screenContainer = new ScreenContainer
{
RelativeSizeAxes = Axes.Both,
}
},
new Container
{
Name = "Top bar",
RelativeSizeAxes = Axes.X,
Height = 40,
Children = new Drawable[]
{
new EditorMenuBar
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
RelativeSizeAxes = Axes.Both,
MaxHeight = 600,
Items = new[]
{
new MenuItem(CommonStrings.MenuBarFile)
{
Items = createFileMenuItems().ToList()
},
new MenuItem(CommonStrings.MenuBarEdit)
{
Items = new[]
{
undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo) { Hotkey = new Hotkey(PlatformAction.Undo) },
redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo) { Hotkey = new Hotkey(PlatformAction.Redo) },
new OsuMenuItemSpacer(),
cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut) { Hotkey = new Hotkey(PlatformAction.Cut) },
copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy) { Hotkey = new Hotkey(PlatformAction.Copy) },
pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste) { Hotkey = new Hotkey(PlatformAction.Paste) },
cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone) { Hotkey = new Hotkey(GlobalAction.EditorCloneSelection) },
}
},
new MenuItem(CommonStrings.MenuBarView)
{
Items = new[]
{
new MenuItem(EditorStrings.Timeline)
{
Items =
[
new WaveformOpacityMenuItem(config.GetBindable<float>(OsuSetting.EditorWaveformOpacity)),
new ToggleMenuItem(EditorStrings.TimelineShowTimingChanges)
{
State = { BindTarget = editorTimelineShowTimingChanges }
},
new ToggleMenuItem(EditorStrings.TimelineShowTicks)
{
State = { BindTarget = editorTimelineShowTicks }
},
new ToggleMenuItem(EditorStrings.TimelineShowBreaks)
{
State = { BindTarget = editorTimelineShowBreaks }
},
]
},
new OsuMenuItemSpacer(),
new BackgroundDimMenuItem(editorBackgroundDim),
new ToggleMenuItem("Show storyboard")
{
State = { BindTarget = editorShowStoryboard },
},
new OsuMenuItemSpacer(),
new ToggleMenuItem(EditorStrings.ShowHitMarkers)
{
State = { BindTarget = editorHitMarkers },
},
new ToggleMenuItem(EditorStrings.AutoSeekOnPlacement)
{
State = { BindTarget = editorAutoSeekOnPlacement },
},
new ToggleMenuItem(EditorStrings.LimitedDistanceSnap)
{
State = { BindTarget = editorLimitedDistanceSnap },
},
new ToggleMenuItem(EditorStrings.ContractSidebars)
{
State = { BindTarget = editorContractSidebars }
},
}
},
new MenuItem(EditorStrings.Timing)
{
Items = new MenuItem[]
{
new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime),
bookmarkController.Menu,
}
}
}
},
screenSwitcher = new EditorScreenSwitcherControl
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
X = -10,
Current = Mode,
},
},
},
bottomBar = new BottomBar(),
MutationTracker,
}
});
changeHandler?.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true);
changeHandler?.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true);
editorBackgroundDim.BindValueChanged(_ => setUpBackground());
}
[Resolved]
private MusicController musicController { get; set; }
protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen(Beatmap.Value);
protected override void LoadComplete()
{
base.LoadComplete();
setUpClipboardActionAvailability();
Mode.Value = isNewBeatmap ? EditorScreenMode.SongSetup : EditorScreenMode.Compose;
Mode.BindValueChanged(onModeChanged, true);
MutationTracker.InProgress.BindValueChanged(_ =>
{
foreach (var item in saveRelatedMenuItems)
item.Action.Disabled = MutationTracker.InProgress.Value;
}, true);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
// redundant (should have happened via a `resetTrack()` call in `OnExiting()`), but done for safety
musicController.TrackChanged -= onTrackChanged;
}
private void onTrackChanged(WorkingBeatmap working, TrackChangeDirection direction) => clock.ChangeSource(working.Track);
/// <summary>
/// Creates an <see cref="EditorState"/> instance representing the current state of the editor.
/// </summary>
/// <param name="nextRuleset">
/// The ruleset of the next beatmap to be shown, in the case of difficulty switch.
/// <see langword="null"/> indicates that the beatmap will not be changing.
/// </param>
public EditorState GetState([CanBeNull] RulesetInfo nextRuleset = null) => new EditorState
{
Time = clock.CurrentTimeAccurate,
ClipboardContent = nextRuleset == null || editorBeatmap.BeatmapInfo.Ruleset.ShortName == nextRuleset.ShortName ? Clipboard.Content.Value : string.Empty
};
/// <summary>
/// Restore the editor to a provided state.
/// </summary>
/// <param name="state">The state to restore.</param>
public void RestoreState([NotNull] EditorState state) => Schedule(() =>
{
clock.Seek(state.Time);
Clipboard.Content.Value = state.ClipboardContent;
});
public void TestGameplay()
{
clock.Stop();
if (HasUnsavedChanges)
{
dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() =>
{
if (!Save()) return false;
pushEditorPlayer();
return true;
})));
}
else
{
pushEditorPlayer();
}
void pushEditorPlayer() => this.Push(new EditorPlayerLoader(this));
}
private bool attemptMutationOperation(Func<bool> mutationOperation)
{
if (MutationTracker.InProgress.Value)
return false;
using (MutationTracker.BeginOperation())
return mutationOperation.Invoke();
}
private bool attemptAsyncMutationOperation(Func<Task> mutationTask)
{
if (MutationTracker.InProgress.Value)
return false;
var operation = MutationTracker.BeginOperation();
var task = mutationTask.Invoke();
task.FireAndForget(operation.Dispose, _ => operation.Dispose());
return true;
}
[CanBeNull]
internal event Action Saved;
/// <summary>
/// Saves the currently edited beatmap.
/// </summary>
/// <returns>Whether the save was successful.</returns>
internal bool Save()
{
if (!canSave)
{
notifications?.Post(new SimpleErrorNotification { Text = "Saving is not supported for this ruleset yet, sorry!" });
return false;
}
try
{
// save the loaded beatmap's data stream.
beatmapManager.Save(editorBeatmap.BeatmapInfo, editorBeatmap.PlayableBeatmap, editorBeatmap.BeatmapSkin);
}
catch (Exception ex)
{
// can fail e.g. due to duplicated difficulty names.
Logger.Error(ex, ex.Message);
return false;
}
// no longer new after first user-triggered save.
isNewBeatmap = false;
updateLastSavedHash();
onScreenDisplay?.Display(new BeatmapEditorToast(ToastStrings.BeatmapSaved, editorBeatmap.BeatmapInfo.GetDisplayTitle()));
Saved?.Invoke();
return true;
}
protected override void Update()
{
base.Update();
clock.ProcessFrame();
discardChangesMenuItem.Action.Disabled = !HasUnsavedChanges;
}
public bool OnPressed(KeyBindingPressEvent<PlatformAction> e)
{
switch (e.Action)
{
case PlatformAction.Cut:
Cut();
return true;
case PlatformAction.Copy:
Copy();
return true;
case PlatformAction.Paste:
Paste();
return true;
case PlatformAction.Undo:
Undo();
return true;
case PlatformAction.Redo:
Redo();
return true;
case PlatformAction.Save:
if (e.Repeat)
return false;
return attemptMutationOperation(Save);
}
return false;
}
public void OnReleased(KeyBindingReleaseEvent<PlatformAction> e)
{
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.ControlPressed || e.AltPressed || e.SuperPressed) return false;
switch (e.Key)
{
case Key.Left:
seek(e, -1);
return true;
case Key.Right:
seek(e, 1);
return true;
// Of those, these two keys are reversed from stable because it feels more natural (and matches mouse wheel scroll directionality).
case Key.Up:
seekControlPoint(-1);
return true;
case Key.Down:
seekControlPoint(1);
return true;
// Track traversal keys.
// Matching osu-stable implementations.
case Key.Z:
if (e.Repeat)
return false;
// Seek to first object time, or track start if already there.
double? firstObjectTime = editorBeatmap.HitObjects.FirstOrDefault()?.StartTime;
if (firstObjectTime == null || clock.CurrentTime == firstObjectTime)
clock.Seek(0);
else
clock.Seek(firstObjectTime.Value);
return true;
case Key.X:
if (e.Repeat)
return false;
// Restart playback from beginning of track.
clock.Seek(0);
clock.Start();
return true;
case Key.C:
if (e.Repeat)
return false;
// Pause or resume.
if (clock.IsRunning)
clock.Stop();
else
clock.Start();
return true;
case Key.V:
if (e.Repeat)
return false;
// Seek to last object time, or track end if already there.
// Note that in osu-stable subsequent presses when at track end won't return to last object.
// This has intentionally been changed to make it more useful.
if (!editorBeatmap.HitObjects.Any())
{
clock.Seek(clock.TrackLength);
return true;
}
double lastObjectTime = editorBeatmap.GetLastObjectTime();
clock.Seek(clock.CurrentTime == lastObjectTime ? clock.TrackLength : lastObjectTime);
return true;
}
return base.OnKeyDown(e);
}
private double scrollAccumulation;
protected override bool OnScroll(ScrollEvent e)
{
if (e.ControlPressed || e.AltPressed || e.SuperPressed)
return false;
const double precision = 1;
double scrollComponent = e.ScrollDelta.X + e.ScrollDelta.Y;
double scrollDirection = Math.Sign(scrollComponent);
// this is a special case to handle the "pivot" scenario.
// if we are precise scrolling in one direction then change our mind and scroll backwards,
// the existing accumulation should be applied in the inverse direction to maintain responsiveness.
if (scrollAccumulation != 0 && Math.Sign(scrollAccumulation) != scrollDirection)
scrollAccumulation = scrollDirection * (precision - Math.Abs(scrollAccumulation));
scrollAccumulation += scrollComponent;
// because we are doing snapped seeking, we need to add up precise scrolls until they accumulate to an arbitrary cut-off.
while (Math.Abs(scrollAccumulation) >= precision)
{
if (scrollAccumulation > 0)
seek(e, -1);
else
seek(e, 1);
scrollAccumulation = scrollAccumulation < 0 ? Math.Min(0, scrollAccumulation + precision) : Math.Max(0, scrollAccumulation - precision);
}
return true;
}
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
// Repeatable actions
switch (e.Action)
{
case GlobalAction.EditorSeekToPreviousHitObject:
if (editorBeatmap.SelectedHitObjects.Any())
return false;
seekHitObject(-1);
return true;
case GlobalAction.EditorSeekToNextHitObject:
if (editorBeatmap.SelectedHitObjects.Any())
return false;
seekHitObject(1);
return true;
case GlobalAction.EditorSeekToPreviousSamplePoint:
seekSamplePoint(-1);
return true;
case GlobalAction.EditorSeekToNextSamplePoint:
seekSamplePoint(1);
return true;
}
if (e.Repeat)
return false;
switch (e.Action)
{
case GlobalAction.EditorCloneSelection:
Clone();
return true;
case GlobalAction.EditorComposeMode:
screenSwitcher.SelectItem(EditorScreenMode.Compose);
return true;
case GlobalAction.EditorDesignMode:
screenSwitcher.SelectItem(EditorScreenMode.Design);
return true;
case GlobalAction.EditorTimingMode:
screenSwitcher.SelectItem(EditorScreenMode.Timing);
return true;
case GlobalAction.EditorSetupMode:
screenSwitcher.SelectItem(EditorScreenMode.SongSetup);
return true;
case GlobalAction.EditorVerifyMode:
screenSwitcher.SelectItem(EditorScreenMode.Verify);
return true;
case GlobalAction.EditorTestGameplay:
bottomBar.TestGameplayButton.TriggerClick();
return true;
case GlobalAction.EditorDiscardUnsavedChanges:
DiscardUnsavedChanges();
return true;
}
return false;
}
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
}
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
setUpBackground();
setUpTrack(seekToStart: true);
}
public override void OnResuming(ScreenTransitionEvent e)
{
base.OnResuming(e);
setUpBackground();
setUpTrack();
}
private void setUpBackground()
{
ApplyToBackground(b =>
{
var editorBackground = (EditorBackgroundScreen)b;
editorBackground.ChangeClockSource(clock);
});
}
public override bool OnExiting(ScreenExitEvent e)
{
currentScreen?.OnExiting(e);
if (!ExitConfirmed)
{
// dialog overlay may not be available in visual tests.
if (dialogOverlay == null)
{
confirmExit();
return true;
}
// if the dialog is already displayed, block exiting until the user explicitly makes a decision.
if (dialogOverlay.CurrentDialog is PromptForSaveDialog saveDialog)
{
saveDialog.Flash();
return true;
}
if (isNewBeatmap || HasUnsavedChanges)
{
updateSampleDisabledState();
dialogOverlay?.Push(new PromptForSaveDialog(confirmExit, confirmExitWithSave, cancelExit));
return true;
}
}
realm.Write(r =>
{
var beatmap = r.Find<BeatmapInfo>(editorBeatmap.BeatmapInfo.ID);
if (beatmap != null)
beatmap.EditorTimestamp = clock.CurrentTime;
});
// `resetTrack()` MUST happen before `refetchBeatmap()`, because along other things, `refetchBeatmap()` causes a global working beatmap change,
// which would cause `EditorClock` to reload the track and automatically reapply adjustments to it if not preceded by `resetTrack()`.
resetTrack();
refetchBeatmap();
return base.OnExiting(e);
}
public override void OnSuspending(ScreenTransitionEvent e)
{
base.OnSuspending(e);
// `resetTrack()` MUST happen before `refetchBeatmap()`, because along other things, `refetchBeatmap()` causes a global working beatmap change,
// which would cause `EditorClock` to reload the track and automatically reapply adjustments to it if not preceded by `resetTrack()`.
resetTrack();
refetchBeatmap();
}
private void refetchBeatmap()
{
// To update the game-wide beatmap with any changes, perform a re-fetch on exit/suspend.
// This is required as the editor makes its local changes via EditorBeatmap
// (which are not propagated outwards to a potentially cached WorkingBeatmap).
var refetchedBeatmap = beatmapManager.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo, true);
if (!(refetchedBeatmap is DummyWorkingBeatmap))
{
Logger.Log(@"Editor providing re-fetched beatmap post edit session");
Beatmap.Value = refetchedBeatmap;
}
}
private void confirmExitWithSave()
{
if (!attemptMutationOperation(Save))
return;
ExitConfirmed = true;
this.Exit();
}
private void confirmExit()
{
// stop the track if playing to allow the parent screen to choose a suitable playback mode.
Beatmap.Value.Track.Stop();
if (isNewBeatmap)
{
// confirming exit without save means we should delete the new beatmap completely.
if (playableBeatmap.BeatmapInfo.BeatmapSet != null)
beatmapManager.Delete(playableBeatmap.BeatmapInfo.BeatmapSet);
// eagerly clear contents before restoring default beatmap to prevent value change callbacks from firing.
ClearInternal();
// in theory this shouldn't be required but due to EF core not sharing instance states 100%
// MusicController is unaware of the changed DeletePending state.
Beatmap.SetDefault();
}
ExitConfirmed = true;
this.Exit();
}
#region Clipboard support
private EditorMenuItem cutMenuItem;
private EditorMenuItem copyMenuItem;
private EditorMenuItem cloneMenuItem;
private EditorMenuItem pasteMenuItem;
private readonly BindableWithCurrent<bool> canCut = new BindableWithCurrent<bool>();
private readonly BindableWithCurrent<bool> canCopy = new BindableWithCurrent<bool>();
private readonly BindableWithCurrent<bool> canPaste = new BindableWithCurrent<bool>();
private void setUpClipboardActionAvailability()
{
canCut.Current.BindValueChanged(cut => cutMenuItem.Action.Disabled = !cut.NewValue, true);
canCopy.Current.BindValueChanged(copy =>
{
copyMenuItem.Action.Disabled = !copy.NewValue;
cloneMenuItem.Action.Disabled = !copy.NewValue;
}, true);
canPaste.Current.BindValueChanged(paste => pasteMenuItem.Action.Disabled = !paste.NewValue, true);
}
private void rebindClipboardBindables()
{
canCut.Current = currentScreen.CanCut;
canCopy.Current = currentScreen.CanCopy;
canPaste.Current = currentScreen.CanPaste;
}
protected void Cut() => currentScreen?.Cut();
protected void Copy() => currentScreen?.Copy();
protected void Clone()
{
// Avoid attempting to clone if copying is not available (as it may result in pasting something unexpected).
if (!canCopy.Value)
return;
// This is an initial implementation just to get an idea of how people used this function.
// There are a couple of differences from osu!stable's implementation which will require more work to match:
// - The "clipboard" is not populated during the duplication process.
// - The duplicated hitobjects are inserted after the original pattern (add one beat_length and then quantize using beat snap).
// - The duplicated hitobjects are selected (but this is also applied for all paste operations so should be changed there).
Copy();
Paste();
}
protected void Paste() => currentScreen?.Paste();
#endregion
protected void Undo() => changeHandler?.RestoreState(-1);
protected void Redo() => changeHandler?.RestoreState(1);
protected void DiscardUnsavedChanges()
{
if (!HasUnsavedChanges)
return;
// we're not doing this via `changeHandler` because `changeHandler` has limited number of undo actions
// and therefore there's no guarantee that it even *has* the beatmap's last saved state in its history still.
dialogOverlay.Push(new DiscardUnsavedChangesDialog(() =>
{
updateLastSavedHash(); // without this a second dialog will show (the standard "save unsaved changes" one that shows on exit).
SwitchToDifficulty(editorBeatmap.BeatmapInfo);
}));
}
protected void SetPreviewPointToCurrentTime()
{
editorBeatmap.PreviewTime.Value = (int)clock.CurrentTime;
}
private void setUpTrack(bool seekToStart = false)
{
clock.Stop();
if (seekToStart)
{
double targetTime = 0;
if (editorBeatmap.BeatmapInfo.EditorTimestamp != null)
{
targetTime = editorBeatmap.BeatmapInfo.EditorTimestamp.Value;
}
else if (Beatmap.Value.Beatmap.HitObjects.Count > 0)
{
// seek to one beat length before the first hitobject
targetTime = Beatmap.Value.Beatmap.HitObjects[0].StartTime;
targetTime -= Beatmap.Value.Beatmap.ControlPointInfo.TimingPointAt(targetTime).BeatLength;
}
clock.Seek(Math.Max(0, targetTime));
}
clock.BindAdjustments();
musicController.TrackChanged += onTrackChanged;
}
private void resetTrack()
{
clock.Stop();
clock.UnbindAdjustments();
musicController.TrackChanged -= onTrackChanged;
}
private void onModeChanged(ValueChangedEvent<EditorScreenMode> e)
{
var lastScreen = currentScreen;
lastScreen?.Hide();
try
{
if ((currentScreen = screenContainer.SingleOrDefault(s => s.Type == e.NewValue)) != null)
{
screenContainer.ChangeChildDepth(currentScreen, lastScreen?.Depth + 1 ?? 0);
currentScreen.Show();
return;
}
switch (e.NewValue)
{
case EditorScreenMode.SongSetup:
currentScreen = new SetupScreen();
break;
case EditorScreenMode.Compose:
currentScreen = new ComposeScreen();
break;
case EditorScreenMode.Design:
currentScreen = new DesignScreen();
break;
case EditorScreenMode.Timing:
currentScreen = new TimingScreen();
break;
case EditorScreenMode.Verify:
currentScreen = new VerifyScreen();
break;
default:
throw new InvalidOperationException("Editor menu bar switched to an unsupported mode");
}
screenContainer.LoadComponentAsync(currentScreen, newScreen =>
{
if (newScreen == currentScreen)
{
screenContainer.Add(newScreen);
newScreen.Show();
}
});
}
finally
{
if (Mode.Value != EditorScreenMode.Compose)
ComposerFocusMode.Value = false;
updateSampleDisabledState();
rebindClipboardBindables();
}
}
/// <summary>
/// Forces a reload of the compose screen after significant configuration changes.
/// </summary>
public void ReloadComposeScreen()
{
screenContainer.SingleOrDefault(s => s.Type == EditorScreenMode.Compose)?.RemoveAndDisposeImmediately();
// If not currently on compose screen, the reload will happen on next mode change.
// That said, control points *can* change on compose screen (e.g. via undo), so we have to handle that case too.
if (Mode.Value == EditorScreenMode.Compose)
Mode.TriggerChange();
}
[CanBeNull]
private ScheduledDelegate playbackDisabledDebounce;
private EditorScreenSwitcherControl screenSwitcher;
private void updateSampleDisabledState()
{
bool shouldDisableSamples = clock.SeekingOrStopped.Value
|| currentScreen is not ComposeScreen
|| editorBeatmap.UpdateInProgress.Value
|| dialogOverlay?.CurrentDialog != null;
playbackDisabledDebounce?.Cancel();
if (shouldDisableSamples)
{
samplePlaybackDisabled.Value = true;
}
else
{
// Debounce re-enabling arbitrarily high enough to avoid flip-flopping during beatmap updates
// or rapid user seeks.
playbackDisabledDebounce = Scheduler.AddDelayed(() => samplePlaybackDisabled.Value = false, 50);
}
}
private void seekControlPoint(int direction)
{
// In the case of a backwards seek while playing, it can be hard to jump before a timing point.
// Adding some lenience here makes it more user-friendly.
double seekLenience = clock.IsRunning ? 1000 * ((IAdjustableClock)clock).Rate : 0;
ControlPoint found = direction < 1
? editorBeatmap.ControlPointInfo.AllControlPoints.LastOrDefault(p => p.Time < clock.CurrentTime - seekLenience)
: editorBeatmap.ControlPointInfo.AllControlPoints.FirstOrDefault(p => p.Time > clock.CurrentTime);
if (found != null)
clock.Seek(found.Time);
}
private void seekHitObject(int direction)
{
var found = direction < 1
? editorBeatmap.HitObjects.LastOrDefault(p => p.StartTime < clock.CurrentTimeAccurate)
: editorBeatmap.HitObjects.FirstOrDefault(p => p.StartTime > clock.CurrentTimeAccurate);
if (found != null)
clock.SeekSmoothlyTo(found.StartTime);
}
private void seekSamplePoint(int direction)
{
double currentTime = clock.CurrentTimeAccurate;
// Check if we are currently inside a hit object with node samples, if so seek to the next node sample point
var current = direction < 1
? editorBeatmap.HitObjects.LastOrDefault(p => p is IHasRepeats r && p.StartTime < currentTime && r.EndTime >= currentTime)
: editorBeatmap.HitObjects.LastOrDefault(p => p is IHasRepeats r && p.StartTime <= currentTime && r.EndTime > currentTime);
if (current != null)
{
// Find the next node sample point
var r = (IHasRepeats)current;
double[] nodeSamplePointTimes = new double[r.RepeatCount + 3];
nodeSamplePointTimes[0] = current.StartTime;
// The sample point for the main samples is sandwiched between the head and the first repeat
nodeSamplePointTimes[1] = current.StartTime + r.Duration / r.SpanCount() / 2;
for (int i = 0; i < r.SpanCount(); i++)
{
nodeSamplePointTimes[i + 2] = current.StartTime + r.Duration * (i + 1) / r.SpanCount();
}
double found = direction < 1
? nodeSamplePointTimes.Last(p => p < currentTime)
: nodeSamplePointTimes.First(p => p > currentTime);
clock.SeekSmoothlyTo(found);
}
else
{
if (direction < 1)
{
current = editorBeatmap.HitObjects.LastOrDefault(p => p.StartTime < currentTime);
if (current != null)
clock.SeekSmoothlyTo(current is IHasRepeats r ? r.EndTime : current.StartTime);
}
else
{
current = editorBeatmap.HitObjects.FirstOrDefault(p => p.StartTime > currentTime);
if (current != null)
clock.SeekSmoothlyTo(current.StartTime);
}
}
// Show the sample edit popover at the current time
ShowSampleEditPopoverRequested?.Invoke(clock.CurrentTimeAccurate);
}
private void seek(UIEvent e, int direction)
{
double amount = e.ShiftPressed ? 4 : 1;
bool trackPlaying = clock.IsRunning;
if (trackPlaying)
{
// generally users are not looking to perform tiny seeks when the track is playing.
// this multiplication undoes the division that will be applied in the underlying seek operation.
// scale by BPM to keep the seek amount constant across all BPMs.
var timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(clock.CurrentTimeAccurate);
amount *= beatDivisor.Value * (timingPoint.BPM / 120);
}
if (direction < 1)
clock.SeekBackward(!trackPlaying, amount);
else
clock.SeekForward(!trackPlaying, amount);
}
private void updateLastSavedHash()
{
lastSavedHash = changeHandler?.CurrentStateHash;
}
private IEnumerable<MenuItem> createFileMenuItems()
{
yield return createDifficultyCreationMenu();
yield return createDifficultySwitchMenu();
yield return new OsuMenuItemSpacer();
yield return new EditorMenuItem(EditorStrings.DeleteDifficulty, MenuItemType.Destructive, deleteDifficulty) { Action = { Disabled = Beatmap.Value.BeatmapSetInfo.Beatmaps.Count < 2 } };
yield return new OsuMenuItemSpacer();
var save = new EditorMenuItem(WebCommonStrings.ButtonsSave, MenuItemType.Standard, () => attemptMutationOperation(Save)) { Hotkey = new Hotkey(PlatformAction.Save) };
saveRelatedMenuItems.Add(save);
yield return save;
yield return discardChangesMenuItem = new EditorMenuItem(GlobalActionKeyBindingStrings.EditorDiscardUnsavedChanges, MenuItemType.Destructive, DiscardUnsavedChanges)
{
Hotkey = new Hotkey(GlobalAction.EditorDiscardUnsavedChanges)
};
if (RuntimeInfo.OS != RuntimeInfo.Platform.Android)
{
var export = createExportMenu();
saveRelatedMenuItems.AddRange(export.Items);
yield return export;
}
if (RuntimeInfo.IsDesktop)
{
var externalEdit = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, editExternally);
saveRelatedMenuItems.Add(externalEdit);
yield return externalEdit;
}
bool isSetMadeOfLegacyRulesetBeatmaps = (isNewBeatmap && Ruleset.Value.IsLegacyRuleset())
|| (!isNewBeatmap && Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Ruleset.IsLegacyRuleset()));
bool submissionAvailable = api.Endpoints.BeatmapSubmissionServiceUrl != null;
if (isSetMadeOfLegacyRulesetBeatmaps && submissionAvailable)
{
var upload = new EditorMenuItem(EditorStrings.SubmitBeatmap, MenuItemType.Standard, submitBeatmap);
saveRelatedMenuItems.Add(upload);
yield return upload;
}
if (editorBeatmap.BeatmapInfo.OnlineID > 0)
{
yield return new OsuMenuItemSpacer();
yield return new EditorMenuItem(EditorStrings.OpenInfoPage, MenuItemType.Standard,
() => (Game as OsuGame)?.OpenUrlExternally(editorBeatmap.BeatmapInfo.GetOnlineURL(api, editorBeatmap.BeatmapInfo.Ruleset)));
yield return new EditorMenuItem(EditorStrings.OpenDiscussionPage, MenuItemType.Standard,
() => (Game as OsuGame)?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/beatmapsets/{editorBeatmap.BeatmapInfo.BeatmapSet!.OnlineID}/discussion/{editorBeatmap.BeatmapInfo.OnlineID}"));
}
yield return new OsuMenuItemSpacer();
yield return new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, this.Exit);
}
private EditorMenuItem createExportMenu()
{
var exportItems = new List<MenuItem>
{
new EditorMenuItem(EditorStrings.ExportForEditing, MenuItemType.Standard, () => exportBeatmap(false)),
new EditorMenuItem(EditorStrings.ExportForCompatibility, MenuItemType.Standard, () => exportBeatmap(true)),
};
return new EditorMenuItem(CommonStrings.Export) { Items = exportItems };
}
private void editExternally()
{
if (HasUnsavedChanges)
{
dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() =>
{
if (!Save())
return false;
startEdit();
return true;
})));
}
else
{
startEdit();
}
void startEdit()
{
this.Push(new ExternalEditScreen());
}
}
private void submitBeatmap()
{
if (api.State.Value != APIState.Online)
{
loginOverlay?.Show();
return;
}
if (!editorBeatmap.HitObjects.Any())
{
notifications?.Post(new SimpleNotification
{
Text = BeatmapSubmissionStrings.EmptyBeatmapsCannotBeSubmitted,
});
return;
}
if (HasUnsavedChanges)
{
dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() =>
{
if (!Save())
return false;
startSubmission();
return true;
})));
}
else
{
startSubmission();
}
void startSubmission() => this.Push(new BeatmapSubmissionScreen());
}
private void exportBeatmap(bool legacy)
{
if (HasUnsavedChanges)
{
dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptAsyncMutationOperation(() =>
{
if (!Save())
return Task.CompletedTask;
return runExport();
})));
}
else
{
attemptAsyncMutationOperation(runExport);
}
Task runExport()
{
if (legacy)
return beatmapManager.ExportLegacy(Beatmap.Value.BeatmapSetInfo);
else
return beatmapManager.Export(Beatmap.Value.BeatmapSetInfo);
}
}
/// <summary>
/// Beatmaps of the currently edited set, grouped by ruleset and ordered by difficulty.
/// </summary>
private IOrderedEnumerable<IGrouping<RulesetInfo, BeatmapInfo>> groupedOrderedBeatmaps => Beatmap.Value.BeatmapSetInfo.Beatmaps
.OrderBy(b => b.StarRating)
.GroupBy(b => b.Ruleset)
.OrderBy(group => group.Key);
private void deleteDifficulty()
{
if (dialogOverlay == null)
delete();
else
dialogOverlay.Push(new DeleteDifficultyConfirmationDialog(Beatmap.Value.BeatmapInfo, delete));
void delete()
{
BeatmapInfo difficultyToDelete = playableBeatmap.BeatmapInfo;
var difficultiesBeforeDeletion = groupedOrderedBeatmaps.SelectMany(g => g).ToList();
// if the difficulty being currently deleted has unsaved changes,
// the editor exit flow would prompt for save *after* this method has done its thing.
// this is generally undesirable and also ends up leaving the user in a broken state.
// therefore, just update the last saved hash to make the exit flow think the deleted beatmap is not dirty,
// so that it will not show the save dialog on exit.
updateLastSavedHash();
beatmapManager.DeleteDifficultyImmediately(difficultyToDelete);
int deletedIndex = difficultiesBeforeDeletion.IndexOf(difficultyToDelete);
// of note, we're still working with the cloned version, so indices are all prior to deletion.
BeatmapInfo nextToShow = difficultiesBeforeDeletion[deletedIndex == 0 ? 1 : deletedIndex - 1];
Beatmap.Value = beatmapManager.GetWorkingBeatmap(nextToShow);
SwitchToDifficulty(nextToShow);
}
}
private EditorMenuItem createDifficultyCreationMenu()
{
var rulesetItems = new List<MenuItem>();
foreach (var ruleset in rulesets.AvailableRulesets)
rulesetItems.Add(new EditorMenuItem(ruleset.Name, MenuItemType.Standard, () => CreateNewDifficulty(ruleset)));
saveRelatedMenuItems.AddRange(rulesetItems);
return new EditorMenuItem(EditorStrings.CreateNewDifficulty) { Items = rulesetItems };
}
protected void CreateNewDifficulty(RulesetInfo rulesetInfo)
{
if (isNewBeatmap)
{
dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() =>
{
if (!Save())
return false;
CreateNewDifficulty(rulesetInfo);
return true;
})));
return;
}
if (!rulesetInfo.Equals(editorBeatmap.BeatmapInfo.Ruleset))
{
switchToNewDifficulty(rulesetInfo, false);
return;
}
dialogOverlay.Push(new CreateNewDifficultyDialog(createCopy => switchToNewDifficulty(rulesetInfo, createCopy)));
}
private void switchToNewDifficulty(RulesetInfo rulesetInfo, bool createCopy)
{
switchingDifficulty = true;
loader?.ScheduleSwitchToNewDifficulty(editorBeatmap.BeatmapInfo, rulesetInfo, createCopy, GetState(rulesetInfo));
}
private EditorMenuItem createDifficultySwitchMenu()
{
var difficultyItems = new List<MenuItem>();
foreach (var rulesetBeatmaps in groupedOrderedBeatmaps)
{
if (difficultyItems.Count > 0)
difficultyItems.Add(new OsuMenuItemSpacer());
foreach (var beatmap in rulesetBeatmaps)
{
bool isCurrentDifficulty = playableBeatmap.BeatmapInfo.Equals(beatmap);
var difficultyMenuItem = new DifficultyMenuItem(beatmap, isCurrentDifficulty, SwitchToDifficulty);
difficultyItems.Add(difficultyMenuItem);
}
}
// Ensure difficulty names are updated when modified in the editor.
// Maybe we could trigger less often but this seems to work well enough.
editorBeatmap.SaveStateTriggered += () =>
{
foreach (var beatmapInfo in Beatmap.Value.BeatmapSetInfo.Beatmaps)
{
var menuItem = difficultyItems.OfType<DifficultyMenuItem>().FirstOrDefault(i => i.BeatmapInfo.Equals(beatmapInfo));
if (menuItem != null)
menuItem.Text.Value = string.IsNullOrEmpty(beatmapInfo.DifficultyName) ? "(unnamed)" : beatmapInfo.DifficultyName;
}
};
return new EditorMenuItem(EditorStrings.ChangeDifficulty) { Items = difficultyItems };
}
public void SwitchToDifficulty(BeatmapInfo nextBeatmap)
{
switchingDifficulty = true;
loader?.ScheduleSwitchToExistingDifficulty(nextBeatmap, GetState(nextBeatmap.Ruleset));
}
private void cancelExit()
{
updateSampleDisabledState();
loader?.CancelPendingDifficultySwitch();
}
public Task<bool> SaveAndReload()
{
var tcs = new TaskCompletionSource<bool>();
dialogOverlay.Push(new SaveAndReloadEditorDialog(
reload: () =>
{
bool reloadedSuccessfully = attemptMutationOperation(() =>
{
if (!Save())
return false;
SwitchToDifficulty(editorBeatmap.BeatmapInfo);
return true;
});
tcs.SetResult(reloadedSuccessfully);
},
cancel: () => tcs.SetResult(false)));
return tcs.Task;
}
public bool HandleTimestamp(string timestamp, bool notifyOnError = false)
{
if (!EditorTimestampParser.TryParse(timestamp, out var timeSpan, out string selection))
{
if (notifyOnError)
{
Schedule(() => notifications?.Post(new SimpleErrorNotification
{
Icon = FontAwesome.Solid.ExclamationTriangle,
Text = EditorStrings.FailedToParseEditorLink
}));
}
return false;
}
editorBeatmap.SelectedHitObjects.Clear();
if (clock.IsRunning)
clock.Stop();
double position = timeSpan.Value.TotalMilliseconds;
if (string.IsNullOrEmpty(selection))
{
clock.SeekSmoothlyTo(position);
return true;
}
// Seek to the next closest HitObject instead
HitObject nextObject = editorBeatmap.HitObjects.FirstOrDefault(x => x.StartTime >= position);
if (nextObject != null)
position = nextObject.StartTime;
clock.SeekSmoothlyTo(position);
Mode.Value = EditorScreenMode.Compose;
// Delegate handling the selection to the ruleset.
currentScreen.Dependencies.Get<HitObjectComposer>().SelectFromTimestamp(position, selection);
return true;
}
public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime);
public double GetBeatLengthAtTime(double referenceTime) => editorBeatmap.GetBeatLengthAtTime(referenceTime);
public int BeatDivisor => beatDivisor.Value;
ControlPointInfo IBeatSyncProvider.ControlPoints => editorBeatmap.ControlPointInfo;
IClock IBeatSyncProvider.Clock => clock;
ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => Beatmap.Value.TrackLoaded ? Beatmap.Value.Track.CurrentAmplitudes : ChannelAmplitudes.Empty;
private partial class BeatmapEditorToast : Toast
{
public BeatmapEditorToast(LocalisableString value, string beatmapDisplayName)
: base(InputSettingsStrings.EditorSection, value, beatmapDisplayName)
{
}
}
private partial class ScreenContainer : Container<EditorScreen>
{
public new Task LoadComponentAsync<TLoadable>([NotNull] TLoadable component, Action<TLoadable> onLoaded = null, CancellationToken cancellation = default, Scheduler scheduler = null)
where TLoadable : Drawable
=> base.LoadComponentAsync(component, onLoaded, cancellation, scheduler);
}
}
}