mirror of
https://github.com/ppy/osu.git
synced 2025-01-19 02:52:54 +08:00
Merge pull request #28511 from bdach/navigate-to-timestamp
Allow to jump to a specific timestamp via bottom bar in editor
This commit is contained in:
commit
5c2555588d
43
osu.Game.Tests/Editing/EditorTimestampParserTest.cs
Normal file
43
osu.Game.Tests/Editing/EditorTimestampParserTest.cs
Normal file
@ -0,0 +1,43 @@
|
||||
// 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.
|
||||
|
||||
using System;
|
||||
using NUnit.Framework;
|
||||
using osu.Game.Rulesets.Edit;
|
||||
|
||||
namespace osu.Game.Tests.Editing
|
||||
{
|
||||
[TestFixture]
|
||||
public class EditorTimestampParserTest
|
||||
{
|
||||
private static readonly object?[][] test_cases =
|
||||
{
|
||||
new object?[] { ":", false, null, null },
|
||||
new object?[] { "1", true, TimeSpan.FromMilliseconds(1), null },
|
||||
new object?[] { "99", true, TimeSpan.FromMilliseconds(99), null },
|
||||
new object?[] { "320000", true, TimeSpan.FromMilliseconds(320000), null },
|
||||
new object?[] { "1:2", true, new TimeSpan(0, 0, 1, 2), null },
|
||||
new object?[] { "1:02", true, new TimeSpan(0, 0, 1, 2), null },
|
||||
new object?[] { "1:92", false, null, null },
|
||||
new object?[] { "1:002", false, null, null },
|
||||
new object?[] { "1:02:3", true, new TimeSpan(0, 0, 1, 2, 3), null },
|
||||
new object?[] { "1:02:300", true, new TimeSpan(0, 0, 1, 2, 300), null },
|
||||
new object?[] { "1:02:3000", false, null, null },
|
||||
new object?[] { "1:02:300 ()", false, null, null },
|
||||
new object?[] { "1:02:300 (1,2,3)", true, new TimeSpan(0, 0, 1, 2, 300), "1,2,3" },
|
||||
};
|
||||
|
||||
[TestCaseSource(nameof(test_cases))]
|
||||
public void TestTryParse(string timestamp, bool expectedSuccess, TimeSpan? expectedParsedTime, string? expectedSelection)
|
||||
{
|
||||
bool actualSuccess = EditorTimestampParser.TryParse(timestamp, out var actualParsedTime, out string? actualSelection);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(actualSuccess, Is.EqualTo(expectedSuccess));
|
||||
Assert.That(actualParsedTime, Is.EqualTo(expectedParsedTime));
|
||||
Assert.That(actualSelection, Is.EqualTo(expectedSelection));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -271,7 +271,7 @@ namespace osu.Game.Online.Chat
|
||||
handleAdvanced(advanced_link_regex, result, startIndex);
|
||||
|
||||
// handle editor times
|
||||
handleMatches(EditorTimestampParser.TIME_REGEX, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}edit/{{0}}", result, startIndex, LinkAction.OpenEditorTimestamp);
|
||||
handleMatches(EditorTimestampParser.TIME_REGEX_STRICT, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}edit/{{0}}", result, startIndex, LinkAction.OpenEditorTimestamp);
|
||||
|
||||
// handle channels
|
||||
handleMatches(channel_regex, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}chan/{{0}}", result, startIndex, LinkAction.OpenChannel);
|
||||
|
@ -596,7 +596,7 @@ namespace osu.Game
|
||||
return;
|
||||
}
|
||||
|
||||
editor.HandleTimestamp(timestamp);
|
||||
editor.HandleTimestamp(timestamp, notifyOnError: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -9,13 +9,41 @@ namespace osu.Game.Rulesets.Edit
|
||||
{
|
||||
public static class EditorTimestampParser
|
||||
{
|
||||
// 00:00:000 (...) - test
|
||||
// original osu-web regex: https://github.com/ppy/osu-web/blob/3b1698639244cfdaf0b41c68bfd651ea729ec2e3/resources/js/utils/beatmapset-discussion-helper.ts#L78
|
||||
public static readonly Regex TIME_REGEX = new Regex(@"\b(((?<minutes>\d{2,}):(?<seconds>[0-5]\d)[:.](?<milliseconds>\d{3}))(?<selection>\s\([^)]+\))?)", RegexOptions.Compiled);
|
||||
/// <summary>
|
||||
/// Used for parsing in contexts where we don't want e.g. normal times of day to be parsed as timestamps (e.g. chat)
|
||||
/// Original osu-web regex: https://github.com/ppy/osu-web/blob/3b1698639244cfdaf0b41c68bfd651ea729ec2e3/resources/js/utils/beatmapset-discussion-helper.ts#L78
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// 00:00:000 (...) - test
|
||||
/// </example>
|
||||
public static readonly Regex TIME_REGEX_STRICT = new Regex(@"\b(((?<minutes>\d{2,}):(?<seconds>[0-5]\d)[:.](?<milliseconds>\d{3}))(?<selection>\s\([^)]+\))?)", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Used for editor-specific context wherein we want to try as hard as we can to process user input as a timestamp.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// <list type="bullet">
|
||||
/// <item>1 - parses to 00:00:001 (bare numbers are treated as milliseconds)</item>
|
||||
/// <item>1:2 - parses to 01:02:000</item>
|
||||
/// <item>1:02 - parses to 01:02:000</item>
|
||||
/// <item>1:92 - does not parse</item>
|
||||
/// <item>1:02:3 - parses to 01:02:003</item>
|
||||
/// <item>1:02:300 - parses to 01:02:300</item>
|
||||
/// <item>1:02:300 (1,2,3) - parses to 01:02:300 with selection</item>
|
||||
/// </list>
|
||||
/// </example>
|
||||
private static readonly Regex time_regex_lenient = new Regex(@"^(((?<minutes>\d{1,3}):(?<seconds>([0-5]?\d))([:.](?<milliseconds>\d{0,3}))?)(?<selection>\s\([^)]+\))?)$", RegexOptions.Compiled);
|
||||
|
||||
public static bool TryParse(string timestamp, [NotNullWhen(true)] out TimeSpan? parsedTime, out string? parsedSelection)
|
||||
{
|
||||
Match match = TIME_REGEX.Match(timestamp);
|
||||
if (double.TryParse(timestamp, out double msec))
|
||||
{
|
||||
parsedTime = TimeSpan.FromMilliseconds(msec);
|
||||
parsedSelection = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
Match match = time_regex_lenient.Match(timestamp);
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
@ -24,16 +52,14 @@ namespace osu.Game.Rulesets.Edit
|
||||
return false;
|
||||
}
|
||||
|
||||
bool result = true;
|
||||
int timeMin, timeSec, timeMsec;
|
||||
|
||||
result &= int.TryParse(match.Groups[@"minutes"].Value, out int timeMin);
|
||||
result &= int.TryParse(match.Groups[@"seconds"].Value, out int timeSec);
|
||||
result &= int.TryParse(match.Groups[@"milliseconds"].Value, out int timeMsec);
|
||||
int.TryParse(match.Groups[@"minutes"].Value, out timeMin);
|
||||
int.TryParse(match.Groups[@"seconds"].Value, out timeSec);
|
||||
int.TryParse(match.Groups[@"milliseconds"].Value, out timeMsec);
|
||||
|
||||
// somewhat sane limit for timestamp duration (10 hours).
|
||||
result &= timeMin < 600;
|
||||
|
||||
if (!result)
|
||||
if (timeMin >= 600)
|
||||
{
|
||||
parsedTime = null;
|
||||
parsedSelection = null;
|
||||
@ -42,8 +68,7 @@ namespace osu.Game.Rulesets.Edit
|
||||
|
||||
parsedTime = TimeSpan.FromMinutes(timeMin) + TimeSpan.FromSeconds(timeSec) + TimeSpan.FromMilliseconds(timeMsec);
|
||||
parsedSelection = match.Groups[@"selection"].Value.Trim();
|
||||
if (!string.IsNullOrEmpty(parsedSelection))
|
||||
parsedSelection = parsedSelection[1..^1];
|
||||
parsedSelection = !string.IsNullOrEmpty(parsedSelection) ? parsedSelection[1..^1] : null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -4,8 +4,12 @@
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Game.Graphics.Sprites;
|
||||
using osu.Framework.Allocation;
|
||||
using osu.Framework.Graphics.Containers;
|
||||
using osu.Framework.Graphics.Shapes;
|
||||
using osu.Game.Extensions;
|
||||
using osu.Game.Graphics;
|
||||
using osu.Game.Graphics.Containers;
|
||||
using osu.Game.Graphics.UserInterface;
|
||||
using osu.Game.Overlays;
|
||||
using osuTK;
|
||||
|
||||
@ -13,7 +17,6 @@ namespace osu.Game.Screens.Edit.Components
|
||||
{
|
||||
public partial class TimeInfoContainer : BottomBarContainer
|
||||
{
|
||||
private OsuSpriteText trackTimer = null!;
|
||||
private OsuSpriteText bpm = null!;
|
||||
|
||||
[Resolved]
|
||||
@ -29,14 +32,7 @@ namespace osu.Game.Screens.Edit.Components
|
||||
|
||||
Children = new Drawable[]
|
||||
{
|
||||
trackTimer = new OsuSpriteText
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
Spacing = new Vector2(-2, 0),
|
||||
Font = OsuFont.Torus.With(size: 36, fixedWidth: true, weight: FontWeight.Light),
|
||||
Y = -10,
|
||||
},
|
||||
new TimestampControl(),
|
||||
bpm = new OsuSpriteText
|
||||
{
|
||||
Colour = colours.Orange1,
|
||||
@ -47,19 +43,12 @@ namespace osu.Game.Screens.Edit.Components
|
||||
};
|
||||
}
|
||||
|
||||
private double? lastTime;
|
||||
private double? lastBPM;
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
if (lastTime != editorClock.CurrentTime)
|
||||
{
|
||||
lastTime = editorClock.CurrentTime;
|
||||
trackTimer.Text = editorClock.CurrentTime.ToEditorFormattedString();
|
||||
}
|
||||
|
||||
double newBPM = editorBeatmap.ControlPointInfo.TimingPointAt(editorClock.CurrentTime).BPM;
|
||||
|
||||
if (lastBPM != newBPM)
|
||||
@ -68,5 +57,109 @@ namespace osu.Game.Screens.Edit.Components
|
||||
bpm.Text = @$"{newBPM:0} BPM";
|
||||
}
|
||||
}
|
||||
|
||||
private partial class TimestampControl : OsuClickableContainer
|
||||
{
|
||||
private Container hoverLayer = null!;
|
||||
private OsuSpriteText trackTimer = null!;
|
||||
private OsuTextBox inputTextBox = null!;
|
||||
|
||||
[Resolved]
|
||||
private Editor? editor { get; set; }
|
||||
|
||||
[Resolved]
|
||||
private EditorClock editorClock { get; set; } = null!;
|
||||
|
||||
public TimestampControl()
|
||||
: base(HoverSampleSet.Button)
|
||||
{
|
||||
}
|
||||
|
||||
[BackgroundDependencyLoader]
|
||||
private void load()
|
||||
{
|
||||
AutoSizeAxes = Axes.Both;
|
||||
|
||||
AddRangeInternal(new Drawable[]
|
||||
{
|
||||
hoverLayer = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
Padding = new MarginPadding
|
||||
{
|
||||
Top = 5,
|
||||
Horizontal = -2
|
||||
},
|
||||
Child = new Container
|
||||
{
|
||||
RelativeSizeAxes = Axes.Both,
|
||||
CornerRadius = 5,
|
||||
Masking = true,
|
||||
Children = new Drawable[]
|
||||
{
|
||||
new Box { RelativeSizeAxes = Axes.Both, },
|
||||
}
|
||||
},
|
||||
Alpha = 0,
|
||||
},
|
||||
trackTimer = new OsuSpriteText
|
||||
{
|
||||
Anchor = Anchor.CentreLeft,
|
||||
Origin = Anchor.CentreLeft,
|
||||
Spacing = new Vector2(-2, 0),
|
||||
Font = OsuFont.Torus.With(size: 36, fixedWidth: true, weight: FontWeight.Light),
|
||||
},
|
||||
inputTextBox = new OsuTextBox
|
||||
{
|
||||
Width = 150,
|
||||
Height = 36,
|
||||
Alpha = 0,
|
||||
CommitOnFocusLost = true,
|
||||
},
|
||||
});
|
||||
|
||||
Action = () =>
|
||||
{
|
||||
trackTimer.Alpha = 0;
|
||||
inputTextBox.Alpha = 1;
|
||||
inputTextBox.Text = editorClock.CurrentTime.ToEditorFormattedString();
|
||||
Schedule(() =>
|
||||
{
|
||||
GetContainingFocusManager()!.ChangeFocus(inputTextBox);
|
||||
inputTextBox.SelectAll();
|
||||
});
|
||||
};
|
||||
|
||||
inputTextBox.Current.BindValueChanged(val => editor?.HandleTimestamp(val.NewValue));
|
||||
|
||||
inputTextBox.OnCommit += (_, __) =>
|
||||
{
|
||||
trackTimer.Alpha = 1;
|
||||
inputTextBox.Alpha = 0;
|
||||
};
|
||||
}
|
||||
|
||||
private double? lastTime;
|
||||
private bool showingHoverLayer;
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
|
||||
if (lastTime != editorClock.CurrentTime)
|
||||
{
|
||||
lastTime = editorClock.CurrentTime;
|
||||
trackTimer.Text = editorClock.CurrentTime.ToEditorFormattedString();
|
||||
}
|
||||
|
||||
bool shouldShowHoverLayer = IsHovered && inputTextBox.Alpha == 0;
|
||||
|
||||
if (shouldShowHoverLayer != showingHoverLayer)
|
||||
{
|
||||
hoverLayer.FadeTo(shouldShowHoverLayer ? 0.2f : 0, 400, Easing.OutQuint);
|
||||
showingHoverLayer = shouldShowHoverLayer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1284,16 +1284,20 @@ namespace osu.Game.Screens.Edit
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
public void HandleTimestamp(string timestamp)
|
||||
public bool HandleTimestamp(string timestamp, bool notifyOnError = false)
|
||||
{
|
||||
if (!EditorTimestampParser.TryParse(timestamp, out var timeSpan, out string selection))
|
||||
{
|
||||
Schedule(() => notifications?.Post(new SimpleErrorNotification
|
||||
if (notifyOnError)
|
||||
{
|
||||
Icon = FontAwesome.Solid.ExclamationTriangle,
|
||||
Text = EditorStrings.FailedToParseEditorLink
|
||||
}));
|
||||
return;
|
||||
Schedule(() => notifications?.Post(new SimpleErrorNotification
|
||||
{
|
||||
Icon = FontAwesome.Solid.ExclamationTriangle,
|
||||
Text = EditorStrings.FailedToParseEditorLink
|
||||
}));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
editorBeatmap.SelectedHitObjects.Clear();
|
||||
@ -1306,7 +1310,7 @@ namespace osu.Game.Screens.Edit
|
||||
if (string.IsNullOrEmpty(selection))
|
||||
{
|
||||
clock.SeekSmoothlyTo(position);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Seek to the next closest HitObject instead
|
||||
@ -1321,6 +1325,7 @@ namespace osu.Game.Screens.Edit
|
||||
|
||||
// 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);
|
||||
|
Loading…
Reference in New Issue
Block a user