Skip to content
Open
65 changes: 65 additions & 0 deletions osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@
#nullable disable

using System.Linq;
using System.Collections.Generic;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Testing;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.IO.Serialization;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Screens.Edit.Compose.Components.Timeline;
using osu.Game.Tests.Beatmaps;
Expand All @@ -26,6 +31,17 @@ public partial class TestSceneEditorClipboard : EditorTestScene

protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false);

[Resolved]
private Clipboard hostClipboard { get; set; } = null!;

public override void SetUpSteps()
{
base.SetUpSteps();

// writing arbitrary value to the clipboard to make sure the clipboard contains no hitobects before each test
AddStep("clear clipboard", () => hostClipboard.SetText("dummy text"));
}

[Test]
public void TestCutRemovesObjects()
{
Expand Down Expand Up @@ -164,18 +180,42 @@ public void TestClone()
AddAssert("is three objects", () => EditorBeatmap.HitObjects.Count == 3);
}

[Test]
public void TestCopyCreatesClipboardEntry()
{
var addedObject = new HitCircle { StartTime = 1000 };
AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject));
AddStep("copy hitobject", () => Editor.Copy());

AddAssert("clipboard contains entry for hitobjects", () => hostClipboard.GetCustom(ClipboardContent.CLIPBOARD_FORMAT) != null);
}

[Test]
public void TestCutCreatesClipboardEntry()
{
var addedObject = new HitCircle { StartTime = 1000 };
AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject));
AddStep("cut hitobject", () => Editor.Cut());

AddAssert("clipboard contains entry for hitobjects", () => hostClipboard.GetCustom(ClipboardContent.CLIPBOARD_FORMAT) != null);
}

[Test]
public void TestCutNothing()
{
AddStep("cut hitobject", () => Editor.Cut());
AddAssert("are no objects", () => EditorBeatmap.HitObjects.Count == 0);
AddAssert("clipboard contains no objects", () => hostClipboard.GetCustom(ClipboardContent.CLIPBOARD_FORMAT) == null);
}

[Test]
public void TestCopyNothing()
{
AddStep("copy hitobject", () => Editor.Copy());
AddAssert("are no objects", () => EditorBeatmap.HitObjects.Count == 0);
AddAssert("clipboard contains no objects", () => hostClipboard.GetCustom(ClipboardContent.CLIPBOARD_FORMAT) == null);
}

[Test]
Expand All @@ -201,5 +241,30 @@ public void TestCloneNothing()
AddStep("clone", () => Editor.Clone());
AddAssert("still one object", () => EditorBeatmap.HitObjects.Count == 1);
}

[Test]
public void TestPasteRawClipboardCootent()
{
var hitObjects = new List<HitObject>
{
new HitCircle { StartTime = 1000 }
};

AddStep("store json content in clipboard", () => hostClipboard.SetCustom(ClipboardContent.CLIPBOARD_FORMAT, new ClipboardContent(hitObjects).Serialize()));

AddStep("paste hitobject", () => Editor.Paste());

AddAssert("one object", () => EditorBeatmap.HitObjects.Count == 1);
}

[Test]
public void TestPasteUnparseableClipboardContent()
{
AddStep("store invalid content in clipboard", () => hostClipboard.SetCustom(ClipboardContent.CLIPBOARD_FORMAT, "invalid json content"));

AddStep("paste hitobject", () => Editor.Paste());

AddAssert("zero objects", () => EditorBeatmap.HitObjects.Count == 0);
}
}
}
5 changes: 5 additions & 0 deletions osu.Game/Localisation/NotificationsStrings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ public static class NotificationsStrings
/// </summary>
public static LocalisableString InstallingUpdate => new TranslatableString(getKey(@"installing_update"), @"Installing update...");

/// <summary>
/// "Failed to paste objects: Invalid clipboard content."
/// </summary>
public static LocalisableString InvalidHitObjectClipboardContent => new TranslatableString(getKey(@"invalid_hitobject_clipboard_content"), @"Failed to paste objects: Invalid clipboard content.");

private static string getKey(string key) => $@"{prefix}:{key}";
}
}
7 changes: 7 additions & 0 deletions osu.Game/Screens/Edit/ClipboardContent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public class ClipboardContent
[JsonConverter(typeof(TypedListConverter<HitObject>))]
public IList<HitObject> HitObjects;

public const string CLIPBOARD_FORMAT = "application/x-osu-hitobjects";

public ClipboardContent()
{
}
Expand All @@ -24,5 +26,10 @@ public ClipboardContent(EditorBeatmap editorBeatmap)
{
HitObjects = editorBeatmap.SelectedHitObjects.ToList();
}

public ClipboardContent(IList<HitObject> hitObjects)
{
HitObjects = hitObjects;
}
}
}
52 changes: 37 additions & 15 deletions osu.Game/Screens/Edit/Compose/ComposeScreen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

#nullable disable

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
Expand All @@ -14,8 +16,12 @@
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.IO.Serialization;
using osu.Game.Localisation;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Edit.Compose.Components.Timeline;

namespace osu.Game.Screens.Edit.Compose
Expand All @@ -31,7 +37,8 @@ public partial class ComposeScreen : EditorScreenWithTimeline, IGameplaySettings
[Resolved]
private IGameplaySettings globalGameplaySettings { get; set; }

private Bindable<string> clipboard { get; set; }
[Resolved(CanBeNull = true)]
private INotificationOverlay notifications { get; set; }

private HitObjectComposer composer;

Expand Down Expand Up @@ -79,12 +86,6 @@ private Drawable wrapSkinnableContent(Drawable content)
return new EditorSkinProvidingContainer(EditorBeatmap).WithChild(content);
}

[BackgroundDependencyLoader]
private void load(EditorClipboard clipboard)
{
this.clipboard = clipboard.Content.GetBoundCopy();
}

protected override void LoadComplete()
{
base.LoadComplete();
Expand All @@ -94,7 +95,6 @@ protected override void LoadComplete()
return;

EditorBeatmap.SelectedHitObjects.BindCollectionChanged((_, _) => updateClipboardActionAvailability());
clipboard.BindValueChanged(_ => updateClipboardActionAvailability());
composer.OnLoadComplete += _ => updateClipboardActionAvailability();
updateClipboardActionAvailability();
}
Expand All @@ -116,20 +116,42 @@ public override void Copy()
// regardless of whether anything was even selected at all.
// UX-wise this is generally strange and unexpected, but make it work anyways to preserve muscle memory.
// note that this means that `getTimestamp()` must handle no-selection case, too.
hostClipboard.SetText(getTimestamp());
var clipboardData = new ClipboardData
{
Text = getTimestamp()
};

if (CanCopy.Value)
clipboard.Value = new ClipboardContent(EditorBeatmap).Serialize();
{
clipboardData.CustomFormatValues[ClipboardContent.CLIPBOARD_FORMAT] = new ClipboardContent(EditorBeatmap).Serialize();
}

hostClipboard.SetData(clipboardData);

updateClipboardActionAvailability();
}

public override void Paste()
{
if (!CanPaste.Value)
string clipboardContent = hostClipboard.GetCustom(ClipboardContent.CLIPBOARD_FORMAT);

IList<HitObject> objects;

try
{
objects = clipboardContent?.Deserialize<ClipboardContent>().HitObjects;
}
catch (Exception)
{
notifications?.Post(new SimpleErrorNotification
{
Text = NotificationsStrings.InvalidHitObjectClipboardContent
});
return;
}

var objects = clipboard.Value.Deserialize<ClipboardContent>().HitObjects;

Debug.Assert(objects.Any());
if (objects == null || objects.Count == 0)
return;

double timeOffset = clock.CurrentTime - objects.Min(o => o.StartTime);

Expand All @@ -149,7 +171,7 @@ public override void Paste()
private void updateClipboardActionAvailability()
{
CanCut.Value = CanCopy.Value = EditorBeatmap.SelectedHitObjects.Any();
CanPaste.Value = composer.IsLoaded && !string.IsNullOrEmpty(clipboard.Value);
CanPaste.Value = composer.IsLoaded && !string.IsNullOrEmpty(hostClipboard.GetCustom("osu/hitobjects"));
}

private string getTimestamp()
Expand Down