Skip to content

Commit

Permalink
Merge pull request #28511 from bdach/navigate-to-timestamp
Browse files Browse the repository at this point in the history
Allow to jump to a specific timestamp via bottom bar in editor
  • Loading branch information
peppy authored Jul 3, 2024
2 parents 6313631 + 56cdd83 commit 5c25555
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 38 deletions.
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

0 comments on commit 5c25555

Please sign in to comment.