1
0
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:
Dean Herbert 2024-07-03 22:24:40 +09:00 committed by GitHub
commit 5c2555588d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 204 additions and 38 deletions

View 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));
});
}
}
}

View File

@ -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);

View File

@ -596,7 +596,7 @@ namespace osu.Game
return; return;
} }
editor.HandleTimestamp(timestamp); editor.HandleTimestamp(timestamp, notifyOnError: true);
} }
/// <summary> /// <summary>

View File

@ -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;
} }
} }

View File

@ -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;
}
}
}
} }
} }

View File

@ -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);