Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to jump to a specific timestamp via bottom bar in editor #28511

Merged
merged 10 commits into from
Jul 3, 2024
43 changes: 43 additions & 0 deletions osu.Game.Tests/Editing/EditorTimestampParserTest.cs
Original file line number Diff line number Diff line change
@@ -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));
});
}
}
}
2 changes: 1 addition & 1 deletion osu.Game/Online/Chat/MessageFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ private static MessageFormatterResult format(string toFormat, int startIndex = 0
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);
Expand Down
2 changes: 1 addition & 1 deletion osu.Game/OsuGame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ public void HandleTimestamp(string timestamp)
return;
}

editor.HandleTimestamp(timestamp);
editor.HandleTimestamp(timestamp, notifyOnError: true);
}

/// <summary>
Expand Down
51 changes: 38 additions & 13 deletions osu.Game/Rulesets/Edit/EditorTimestampParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -24,16 +52,14 @@ public static bool TryParse(string timestamp, [NotNullWhen(true)] out TimeSpan?
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;
Expand All @@ -42,8 +68,7 @@ public static bool TryParse(string timestamp, [NotNullWhen(true)] out TimeSpan?

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;
}
}
Expand Down
125 changes: 109 additions & 16 deletions osu.Game/Screens/Edit/Components/TimeInfoContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@
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;

namespace osu.Game.Screens.Edit.Components
{
public partial class TimeInfoContainer : BottomBarContainer
{
private OsuSpriteText trackTimer = null!;
private OsuSpriteText bpm = null!;

[Resolved]
Expand All @@ -29,14 +32,7 @@ private void load(OsuColour colours, OverlayColourProvider colourProvider)

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,
Expand All @@ -47,19 +43,12 @@ private void load(OsuColour colours, OverlayColourProvider colourProvider)
};
}

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)
Expand All @@ -68,5 +57,109 @@ protected override void Update()
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;
}
}
}
}
}
19 changes: 12 additions & 7 deletions osu.Game/Screens/Edit/Editor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1284,16 +1284,20 @@ public Task<bool> Reload()
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();
Expand All @@ -1306,7 +1310,7 @@ public void HandleTimestamp(string timestamp)
if (string.IsNullOrEmpty(selection))
{
clock.SeekSmoothlyTo(position);
return;
return true;
}

// Seek to the next closest HitObject instead
Expand All @@ -1321,6 +1325,7 @@ public void HandleTimestamp(string timestamp)

// 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);
Expand Down
Loading