mirror of
https://github.com/ppy/osu.git
synced 2024-09-22 03:27:24 +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);
|
handleAdvanced(advanced_link_regex, result, startIndex);
|
||||||
|
|
||||||
// handle editor times
|
// 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
|
// handle channels
|
||||||
handleMatches(channel_regex, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}chan/{{0}}", result, startIndex, LinkAction.OpenChannel);
|
handleMatches(channel_regex, "{0}", $@"{OsuGameBase.OSU_PROTOCOL}chan/{{0}}", result, startIndex, LinkAction.OpenChannel);
|
||||||
|
@ -596,7 +596,7 @@ namespace osu.Game
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.HandleTimestamp(timestamp);
|
editor.HandleTimestamp(timestamp, notifyOnError: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -9,13 +9,41 @@ namespace osu.Game.Rulesets.Edit
|
|||||||
{
|
{
|
||||||
public static class EditorTimestampParser
|
public static class EditorTimestampParser
|
||||||
{
|
{
|
||||||
// 00:00:000 (...) - test
|
/// <summary>
|
||||||
// original osu-web regex: https://github.com/ppy/osu-web/blob/3b1698639244cfdaf0b41c68bfd651ea729ec2e3/resources/js/utils/beatmapset-discussion-helper.ts#L78
|
/// Used for parsing in contexts where we don't want e.g. normal times of day to be parsed as timestamps (e.g. chat)
|
||||||
public static readonly Regex TIME_REGEX = new Regex(@"\b(((?<minutes>\d{2,}):(?<seconds>[0-5]\d)[:.](?<milliseconds>\d{3}))(?<selection>\s\([^)]+\))?)", RegexOptions.Compiled);
|
/// 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)
|
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)
|
if (!match.Success)
|
||||||
{
|
{
|
||||||
@ -24,16 +52,14 @@ namespace osu.Game.Rulesets.Edit
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool result = true;
|
int timeMin, timeSec, timeMsec;
|
||||||
|
|
||||||
result &= int.TryParse(match.Groups[@"minutes"].Value, out int timeMin);
|
int.TryParse(match.Groups[@"minutes"].Value, out timeMin);
|
||||||
result &= int.TryParse(match.Groups[@"seconds"].Value, out int timeSec);
|
int.TryParse(match.Groups[@"seconds"].Value, out timeSec);
|
||||||
result &= int.TryParse(match.Groups[@"milliseconds"].Value, out int timeMsec);
|
int.TryParse(match.Groups[@"milliseconds"].Value, out timeMsec);
|
||||||
|
|
||||||
// somewhat sane limit for timestamp duration (10 hours).
|
// somewhat sane limit for timestamp duration (10 hours).
|
||||||
result &= timeMin < 600;
|
if (timeMin >= 600)
|
||||||
|
|
||||||
if (!result)
|
|
||||||
{
|
{
|
||||||
parsedTime = null;
|
parsedTime = null;
|
||||||
parsedSelection = null;
|
parsedSelection = null;
|
||||||
@ -42,8 +68,7 @@ namespace osu.Game.Rulesets.Edit
|
|||||||
|
|
||||||
parsedTime = TimeSpan.FromMinutes(timeMin) + TimeSpan.FromSeconds(timeSec) + TimeSpan.FromMilliseconds(timeMsec);
|
parsedTime = TimeSpan.FromMinutes(timeMin) + TimeSpan.FromSeconds(timeSec) + TimeSpan.FromMilliseconds(timeMsec);
|
||||||
parsedSelection = match.Groups[@"selection"].Value.Trim();
|
parsedSelection = match.Groups[@"selection"].Value.Trim();
|
||||||
if (!string.IsNullOrEmpty(parsedSelection))
|
parsedSelection = !string.IsNullOrEmpty(parsedSelection) ? parsedSelection[1..^1] : null;
|
||||||
parsedSelection = parsedSelection[1..^1];
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,12 @@
|
|||||||
using osu.Framework.Graphics;
|
using osu.Framework.Graphics;
|
||||||
using osu.Game.Graphics.Sprites;
|
using osu.Game.Graphics.Sprites;
|
||||||
using osu.Framework.Allocation;
|
using osu.Framework.Allocation;
|
||||||
|
using osu.Framework.Graphics.Containers;
|
||||||
|
using osu.Framework.Graphics.Shapes;
|
||||||
using osu.Game.Extensions;
|
using osu.Game.Extensions;
|
||||||
using osu.Game.Graphics;
|
using osu.Game.Graphics;
|
||||||
|
using osu.Game.Graphics.Containers;
|
||||||
|
using osu.Game.Graphics.UserInterface;
|
||||||
using osu.Game.Overlays;
|
using osu.Game.Overlays;
|
||||||
using osuTK;
|
using osuTK;
|
||||||
|
|
||||||
@ -13,7 +17,6 @@ namespace osu.Game.Screens.Edit.Components
|
|||||||
{
|
{
|
||||||
public partial class TimeInfoContainer : BottomBarContainer
|
public partial class TimeInfoContainer : BottomBarContainer
|
||||||
{
|
{
|
||||||
private OsuSpriteText trackTimer = null!;
|
|
||||||
private OsuSpriteText bpm = null!;
|
private OsuSpriteText bpm = null!;
|
||||||
|
|
||||||
[Resolved]
|
[Resolved]
|
||||||
@ -29,14 +32,7 @@ namespace osu.Game.Screens.Edit.Components
|
|||||||
|
|
||||||
Children = new Drawable[]
|
Children = new Drawable[]
|
||||||
{
|
{
|
||||||
trackTimer = new OsuSpriteText
|
new TimestampControl(),
|
||||||
{
|
|
||||||
Anchor = Anchor.CentreLeft,
|
|
||||||
Origin = Anchor.CentreLeft,
|
|
||||||
Spacing = new Vector2(-2, 0),
|
|
||||||
Font = OsuFont.Torus.With(size: 36, fixedWidth: true, weight: FontWeight.Light),
|
|
||||||
Y = -10,
|
|
||||||
},
|
|
||||||
bpm = new OsuSpriteText
|
bpm = new OsuSpriteText
|
||||||
{
|
{
|
||||||
Colour = colours.Orange1,
|
Colour = colours.Orange1,
|
||||||
@ -47,19 +43,12 @@ namespace osu.Game.Screens.Edit.Components
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private double? lastTime;
|
|
||||||
private double? lastBPM;
|
private double? lastBPM;
|
||||||
|
|
||||||
protected override void Update()
|
protected override void Update()
|
||||||
{
|
{
|
||||||
base.Update();
|
base.Update();
|
||||||
|
|
||||||
if (lastTime != editorClock.CurrentTime)
|
|
||||||
{
|
|
||||||
lastTime = editorClock.CurrentTime;
|
|
||||||
trackTimer.Text = editorClock.CurrentTime.ToEditorFormattedString();
|
|
||||||
}
|
|
||||||
|
|
||||||
double newBPM = editorBeatmap.ControlPointInfo.TimingPointAt(editorClock.CurrentTime).BPM;
|
double newBPM = editorBeatmap.ControlPointInfo.TimingPointAt(editorClock.CurrentTime).BPM;
|
||||||
|
|
||||||
if (lastBPM != newBPM)
|
if (lastBPM != newBPM)
|
||||||
@ -68,5 +57,109 @@ namespace osu.Game.Screens.Edit.Components
|
|||||||
bpm.Text = @$"{newBPM:0} BPM";
|
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;
|
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))
|
if (!EditorTimestampParser.TryParse(timestamp, out var timeSpan, out string selection))
|
||||||
{
|
{
|
||||||
Schedule(() => notifications?.Post(new SimpleErrorNotification
|
if (notifyOnError)
|
||||||
{
|
{
|
||||||
Icon = FontAwesome.Solid.ExclamationTriangle,
|
Schedule(() => notifications?.Post(new SimpleErrorNotification
|
||||||
Text = EditorStrings.FailedToParseEditorLink
|
{
|
||||||
}));
|
Icon = FontAwesome.Solid.ExclamationTriangle,
|
||||||
return;
|
Text = EditorStrings.FailedToParseEditorLink
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
editorBeatmap.SelectedHitObjects.Clear();
|
editorBeatmap.SelectedHitObjects.Clear();
|
||||||
@ -1306,7 +1310,7 @@ namespace osu.Game.Screens.Edit
|
|||||||
if (string.IsNullOrEmpty(selection))
|
if (string.IsNullOrEmpty(selection))
|
||||||
{
|
{
|
||||||
clock.SeekSmoothlyTo(position);
|
clock.SeekSmoothlyTo(position);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seek to the next closest HitObject instead
|
// Seek to the next closest HitObject instead
|
||||||
@ -1321,6 +1325,7 @@ namespace osu.Game.Screens.Edit
|
|||||||
|
|
||||||
// Delegate handling the selection to the ruleset.
|
// Delegate handling the selection to the ruleset.
|
||||||
currentScreen.Dependencies.Get<HitObjectComposer>().SelectFromTimestamp(position, selection);
|
currentScreen.Dependencies.Get<HitObjectComposer>().SelectFromTimestamp(position, selection);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime);
|
public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime);
|
||||||
|
Loading…
Reference in New Issue
Block a user