// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Transforms; using osu.Framework.Input.Events; using osu.Framework.Layout; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Graphics.Containers; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public class ZoomableScrollContainer : OsuScrollContainer { /// /// The time to zoom into/out of a point. /// All user scroll input will be overwritten during the zoom transform. /// public double ZoomDuration; /// /// The easing with which to transform the zoom. /// public Easing ZoomEasing; private readonly Container zoomedContent; protected override Container Content => zoomedContent; /// /// The current zoom level of . /// It may differ from during transitions. /// public float CurrentZoom { get; private set; } = 1; private bool isZoomSetUp; [Resolved(canBeNull: true)] private IFrameBasedClock editorClock { get; set; } private readonly LayoutValue zoomedContentWidthCache = new LayoutValue(Invalidation.DrawSize); private float minZoom; private float maxZoom; /// /// Creates a with no zoom range. /// Functionality will be disabled until zoom is set up via . /// protected ZoomableScrollContainer() : base(Direction.Horizontal) { base.Content.Add(zoomedContent = new Container { RelativeSizeAxes = Axes.Y, // We must hide content until SetupZoom is called. // If not, a child component that relies on its DrawWidth (via RelativeSizeAxes) may see a very incorrect value // momentarily, as noticed in the TimelineTickDisplay, which would render thousands of ticks incorrectly. Alpha = 0, }); AddLayout(zoomedContentWidthCache); } /// /// Creates a with a defined zoom range. /// public ZoomableScrollContainer(float minimum, float maximum, float initial) : this() { SetupZoom(initial, minimum, maximum); } /// /// Sets up the minimum and maximum range of this zoomable scroll container, along with the initial zoom value. /// /// The initial zoom value, applied immediately. /// The minimum zoom value. /// The maximum zoom value. protected void SetupZoom(float initial, float minimum, float maximum) { if (minimum < 1) throw new ArgumentException($"{nameof(minimum)} ({minimum}) must be >= 1.", nameof(maximum)); if (maximum < 1) throw new ArgumentException($"{nameof(maximum)} ({maximum}) must be >= 1.", nameof(maximum)); if (minimum > maximum) throw new ArgumentException($"{nameof(minimum)} ({minimum}) must be less than {nameof(maximum)} ({maximum})"); if (initial < minimum || initial > maximum) throw new ArgumentException($"{nameof(initial)} ({initial}) must be between {nameof(minimum)} ({minimum}) and {nameof(maximum)} ({maximum})"); minZoom = minimum; maxZoom = maximum; CurrentZoom = zoomTarget = initial; isZoomSetUp = true; zoomedContent.Show(); } /// /// Gets or sets the content zoom level of this . /// public float Zoom { get => zoomTarget; set => updateZoom(value); } private void updateZoom(float value) { if (!isZoomSetUp) return; float newZoom = Math.Clamp(value, minZoom, maxZoom); if (IsLoaded) setZoomTarget(newZoom, ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), zoomedContent).X); else CurrentZoom = zoomTarget = newZoom; } protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); if (!zoomedContentWidthCache.IsValid) updateZoomedContentWidth(); } protected override bool OnScroll(ScrollEvent e) { if (e.AltPressed) { // zoom when holding alt. AdjustZoomRelatively(e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X); return true; } // can't handle scroll correctly while playing. // the editor will handle this case for us. if (editorClock?.IsRunning == true) return false; return base.OnScroll(e); } private void updateZoomedContentWidth() { zoomedContent.Width = DrawWidth * CurrentZoom; zoomedContentWidthCache.Validate(); } public void AdjustZoomRelatively(float change, float? focusPoint = null) { if (!isZoomSetUp) return; const float zoom_change_sensitivity = 0.02f; setZoomTarget(zoomTarget + change * (maxZoom - minZoom) * zoom_change_sensitivity, focusPoint); } private float zoomTarget = 1; private void setZoomTarget(float newZoom, float? focusPoint = null) { zoomTarget = Math.Clamp(newZoom, minZoom, maxZoom); focusPoint ??= zoomedContent.ToLocalSpace(ToScreenSpace(new Vector2(DrawWidth / 2, 0))).X; transformZoomTo(zoomTarget, focusPoint.Value, ZoomDuration, ZoomEasing); OnZoomChanged(); } private void transformZoomTo(float newZoom, float focusPoint, double duration = 0, Easing easing = Easing.None) => this.TransformTo(this.PopulateTransform(new TransformZoom(focusPoint, zoomedContent.DrawWidth, Current), newZoom, duration, easing)); /// /// Invoked when has changed. /// protected virtual void OnZoomChanged() { } private class TransformZoom : Transform { /// /// The focus point in absolute coordinates local to the content. /// private readonly float focusPoint; /// /// The size of the content. /// private readonly float contentSize; /// /// The scroll offset at the start of the transform. /// private readonly float scrollOffset; /// /// Transforms to a new value. /// /// The focus point in absolute coordinates local to the content. /// The size of the content. /// The scroll offset at the start of the transform. public TransformZoom(float focusPoint, float contentSize, float scrollOffset) { this.focusPoint = focusPoint; this.contentSize = contentSize; this.scrollOffset = scrollOffset; } public override string TargetMember => nameof(CurrentZoom); private float valueAt(double time) { if (time < StartTime) return StartValue; if (time >= EndTime) return EndValue; return Interpolation.ValueAt(time, StartValue, EndValue, StartTime, EndTime, Easing); } protected override void Apply(ZoomableScrollContainer d, double time) { float newZoom = valueAt(time); float focusOffset = focusPoint - scrollOffset; float expectedWidth = d.DrawWidth * newZoom; float targetOffset = expectedWidth * (focusPoint / contentSize) - focusOffset; d.CurrentZoom = newZoom; d.updateZoomedContentWidth(); // Temporarily here to make sure ScrollTo gets the correct DrawSize for scrollable area. // TODO: Make sure draw size gets invalidated properly on the framework side, and remove this once it is. d.Invalidate(Invalidation.DrawSize); d.ScrollTo(targetOffset, false); } protected override void ReadIntoStartValue(ZoomableScrollContainer d) => StartValue = d.CurrentZoom; } } }