#nullable enable using System.Runtime.CompilerServices; using ImGuiNET; using T3.Core.DataTypes.Vector; using T3.Core.Utils; using T3.Editor.App; using T3.Editor.Gui.Styling; using T3.Editor.Gui.UiHelpers; using T3.Editor.Gui.Windows.Layouts; using T3.Editor.Gui.Windows.TimeLine; namespace T3.Editor.Gui.Interaction; /// /// Implements transformations and interactions for a canvas that can be zoomed and panned. /// /// /// This should be refactored into two parts: /// - struct ViewTransform {Scroll, Zoom} -> For all transforms and conversion operations /// - sealed class InteractiveCanvas -> For transitions, interactions, etc. /// Other canvas should not inherit from this but use composition. /// IScalableCanvas should be removed /// public partial class ScalableCanvas { public ScalableCanvas(Vector2? initialScale = null) { if (initialScale == null) return; Scale = ScaleTarget = initialScale.Value; } /// /// This needs to be called by the inherited class before drawing its interface. /// public void UpdateCanvas(out InteractionState interactionState, T3Ui.EditingFlags flags = T3Ui.EditingFlags.None) { UpdateWindowRect(); HandleRequestedTransitions(); var io = ImGui.GetIO(); var mouse = new MouseState(io.MousePos, io.MouseDelta, io.MouseWheel); DampScaling(io.DeltaTime); HandleInteraction(flags, mouse, out var zoomed, out var panned); interactionState = new InteractionState(panned, zoomed, mouse); } private void UpdateWindowRect() { if (FillMode == FillModes.FillWindow) { var paddingForFocusBorder = LayoutHandling.FocusMode ? 0 : 1; WindowPos = ImGui.GetWindowPos() + paddingForFocusBorder * Vector2.One; WindowSize = ImGui.GetWindowSize() - paddingForFocusBorder * 2 * Vector2.One; } else { WindowSize = ImGui.GetContentRegionAvail(); WindowPos = ImGui.GetCursorScreenPos(); } } /// /// Convert canvas position (e.g. of an Operator) into screen position /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public virtual Vector2 TransformPositionFloat(Vector2 posOnCanvas) { return (posOnCanvas - Scroll) * Scale + WindowPos; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public Vector2 TransformPosition(Vector2 posOnCanvas) { var v = TransformPositionFloat(posOnCanvas); return new Vector2((int)v.X, (int)v.Y); } /// /// Convert canvas position (e.g. of an Operator) to screen position /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public float TransformX(float xOnCanvas) { return TransformPosition(new Vector2(xOnCanvas, 0)).X; } /// /// Convert canvas position (e.g. of an Operator) to screen position /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public float TransformY(float yOnCanvas) { return TransformPositionFloat(new Vector2(0, yOnCanvas)).Y; } /// /// Convert a screen space position (e.g. from mouse) to canvas coordinates /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public virtual Vector2 InverseTransformPositionFloat(Vector2 screenPos) { return (screenPos - WindowPos) / (Scale) + Scroll; } /// /// Convert screen position to canvas position /// public virtual float InverseTransformX(float xOnScreen) { return InverseTransformPositionFloat(new Vector2(xOnScreen, 0)).X; } /// /// Convert screen position to canvas position /// public float InverseTransformY(float yOnScreen) { return InverseTransformPositionFloat(new Vector2(0, yOnScreen)).Y; } /// /// Convert a direction (e.g. MouseDelta) from ScreenSpace to Canvas /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public Vector2 TransformDirection(Vector2 vectorInCanvas) { return TransformPositionFloat(vectorInCanvas) - TransformPositionFloat(new Vector2(0, 0)); } [MethodImpl(MethodImplOptions.AggressiveInlining)] internal Vector2 TransformDirectionFloored(Vector2 vectorInCanvas) { var s = TransformDirection(vectorInCanvas); return new Vector2((int)s.X, (int)s.Y); } /// /// Convert a direction (e.g. MouseDelta) from ScreenSpace to Canvas /// /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public Vector2 InverseTransformDirection(Vector2 vectorInScreen) { return vectorInScreen / (Scale); } public ImRect TransformRect(ImRect canvasRect) { // NOTE: We have to floor the size instead to min max position to avoid jitter var min = TransformPositionFloat(canvasRect.Min); var max = TransformPositionFloat(canvasRect.Max); var size = max - min; min.X = (int)min.X; min.Y = (int)min.Y; size.X = (int)size.X; size.Y = (int)size.Y; return new ImRect(min, min + size); } public ImRect InverseTransformRect(ImRect screenRect) { return new ImRect(InverseTransformPositionFloat(screenRect.Min), InverseTransformPositionFloat(screenRect.Max)); } /// /// Transform a canvas position to relative position within ImGui-window (e.g. to set ImGui context) /// public Vector2 ChildPosFromCanvas(Vector2 posOnCanvas) { return TransformPositionFloat(posOnCanvas) - WindowPos; } public Vector2 WindowPos { get; private set; } public Vector2 WindowSize { get; private set; } = new Vector2(200, 200); public Vector2 Scale { get; protected set; } = Vector2.One; protected Vector2 ScaleTarget = Vector2.One; //Vector2 IScalableCanvas.ScaleTarget => ScaleTarget; public Vector2 Scroll { get; protected set; } = new(0.0f, 0.0f); protected Vector2 ScrollTarget = new(0.0f, 0.0f); private void DampScaling(float deltaTime) { var completed = Scale.X > 1000 || Math.Abs(Scroll.X - ScrollTarget.X) < 1f && Math.Abs(Scroll.Y - ScrollTarget.Y) < 1f && Math.Abs(Scale.X - ScaleTarget.X) < 0.05f && Math.Abs(Scale.Y - ScaleTarget.Y) < 0.05f; if (completed) { Scroll = ScrollTarget; Scale = ScaleTarget; return; } // Damp scaling var minInCanvas = Scroll; var maxInCanvas = Scroll + WindowSize / Scale; var minTargetInCanvas = ScrollTarget; var maxTargetInCanvas = ScrollTarget + WindowSize / ScaleTarget; var f = Math.Min(deltaTime / UserSettings.Config.ScrollSmoothing.Clamp(0.01f, 0.99f), 1); var min = Vector2.Lerp(minInCanvas, minTargetInCanvas, f); var max = Vector2.Lerp(maxInCanvas, maxTargetInCanvas, f); Scale = WindowSize / (max - min); Scroll = min; if (float.IsNaN(ScaleTarget.X)) ScaleTarget.X = 1; if (float.IsNaN(ScaleTarget.Y)) ScaleTarget.Y = 1; if (float.IsNaN(Scale.X) || float.IsNaN(Scale.Y) || MathF.Sign(ScaleTarget.Y) != MathF.Sign(Scale.Y)) Scale = ScaleTarget; if (float.IsNaN(ScrollTarget.X)) ScrollTarget.X = 0; if (float.IsNaN(ScrollTarget.Y)) ScrollTarget.Y = 0; if (float.IsNaN(Scroll.X) || float.IsNaN(Scroll.Y)) Scroll = ScrollTarget; } private void HandleInteraction(T3Ui.EditingFlags flags, in MouseState mouseState, out bool zoomed, out bool panned) { zoomed = false; panned = false; if (_draggedCanvas == this && !ImGui.IsMouseDragging(ImGuiMouseButton.Right)) _draggedCanvas = null; // ImGuiHoveredFlags.RectOnly includes AllowWhenOverlappedByItem/ByWindow which 1.91 // forbids for IsWindowHovered (item-level flags). Use only the window-level allowed bits. var isDirectlyHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows | ImGuiHoveredFlags.AllowWhenBlockedByPopup | ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); var isInteractable = isDirectlyHovered && !FrameStats.Last.OpenedPopupHovered; var isPanning = _draggedCanvas == this; var isAnotherWindowPanning = _draggedCanvas != null && _draggedCanvas != this || CustomComponents.IsAnotherWindowDragScrolling(this); //DrawCanvasDebugInfos(mouseState.Position, isDirectlyHovered, isInteractable, isPanning); if ((flags & T3Ui.EditingFlags.PreventMouseInteractions) != 0) return; var isAnotherItemActive = FrameStats.Last.OpenedPopupCapturedMouse; //ImGui.IsAnyItemActive(); if ( !(isAnotherWindowPanning || isAnotherItemActive) && (isInteractable || isPanning) && (flags & T3Ui.EditingFlags.PreventPanningWithMouse) == 0 && (( ImGui.IsMouseDragging(ImGuiMouseButton.Left) && ImGui.GetIO().KeyAlt && this is not TimeLineCanvas) || (!UserSettings.Config.MiddleMouseButtonZooms && ImGui.IsMouseDragging(ImGuiMouseButton.Middle) && !ImGui.GetIO().KeyAlt) || (ImGui.IsMouseDragging(ImGuiMouseButton.Right) && !ImGui.GetIO().KeyAlt)) ) { var parentScaleTarget = Parent?.ScaleTarget ?? Vector2.One; ScrollTarget -= TransformPanDelta(mouseState.Delta) / (parentScaleTarget * ScaleTarget); _draggedCanvas = this; } var preventZoom = (flags & T3Ui.EditingFlags.PreventZoomWithMouseWheel) != 0; if (isInteractable && !preventZoom) { if (UserSettings.Config.UseTouchPadPanning) { if (isDirectlyHovered) { ScrollTarget += new Vector2(ImGui.GetIO().MouseWheelH * 220, -ImGui.GetIO().MouseWheel * 220) / ScaleTarget; //MouseWheelPanning.Delta * new Vector2(-1,1) / ScaleTarget; var zoomFactor= ComputeZoomDeltaFromMouseWheel(MouseWheelPanning.PinchZoomDelta); ApplyZoomDelta(mouseState.Position, zoomFactor, out zoomed); } } else { ZoomWithMouseWheel(mouseState, out zoomed); } } ZoomWithDrag(); ScaleTarget = ClampScaleToValidRange(ScaleTarget); } private static ScalableCanvas? _draggedCanvas; internal static bool IsAnyCanvasDragged => _draggedCanvas != null; internal protected virtual Vector2 ClampScaleToValidRange(Vector2 scale) { if (IsCurveCanvas) return scale; return this is TimeLineCanvas ? new Vector2(scale.X.Clamp(0.01f, 5000), scale.Y.Clamp(0.01f, 5000)) : new Vector2(scale.X.Clamp(0.02f, 40), scale.Y.Clamp(0.02f, 40)); } public void ZoomWithMouseWheel(MouseState mouseState, out bool zoomed) { var ioMouseWheel = mouseState.ScrollWheel; if (ioMouseWheel == 0) { zoomed = false; return; } //return 1; var zoomDelta = ComputeZoomDeltaFromMouseWheel(ioMouseWheel); ApplyZoomDelta(mouseState.Position, zoomDelta, out zoomed); } /// /// Hook that lets subclasses constrain which axes of a mouse pan actually apply. /// Default: identity (both axes pan). Return Vector2(delta.X, 0) to disable Y pan, etc. /// protected virtual Vector2 TransformPanDelta(Vector2 delta) => delta; /// /// Copy the U (X) components of scale/scroll (and their damping targets) from another /// canvas. Used so timeline panes stay time-aligned when the user zooms/pans U in either. /// internal void SyncXFrom(ScalableCanvas other) { Scale = new Vector2(other.Scale.X, Scale.Y); Scroll = new Vector2(other.Scroll.X, Scroll.Y); ScaleTarget = new Vector2(other.ScaleTarget.X, ScaleTarget.Y); ScrollTarget = new Vector2(other.ScrollTarget.X, ScrollTarget.Y); } /// Inverse of : push this canvas's U state back to another. internal void SyncXTo(ScalableCanvas other) { other.Scale = new Vector2(Scale.X, other.Scale.Y); other.Scroll = new Vector2(Scroll.X, other.Scroll.Y); other.ScaleTarget = new Vector2(ScaleTarget.X, other.ScaleTarget.Y); other.ScrollTarget = new Vector2(ScrollTarget.X, other.ScrollTarget.Y); } /// Current (damped) view scope — counterpart to . internal CanvasScope GetCurrentScope() => new() { Scale = Scale, Scroll = Scroll }; internal void SetCurrentScope(CanvasScope s) { Scale = s.Scale; Scroll = s.Scroll; } internal void SetWindowRect(Vector2 pos, Vector2 size) { WindowPos = pos; WindowSize = size; } /// /// Copies current scope, target scope, and viewport rect from another canvas. /// Used to temporarily redirect the outer canvas's view to a sub-canvas's so downstream /// code that reads TimeLineCanvas.Current.* for transforms lands in the sub's child. /// Caller is responsible for capturing/restoring the previous scope/rect. /// internal void AdoptViewFrom(ScalableCanvas other) { SetCurrentScope(other.GetCurrentScope()); SetTargetScope(other.GetTargetScope()); SetWindowRect(other.WindowPos, other.WindowSize); } protected virtual void ApplyZoomDelta(Vector2 position, float zoomDelta, out bool zoomed) { zoomed = false; if (Math.Abs(zoomDelta - 1) < 0.001f) return; var clamped = ClampScaleToValidRange(ScaleTarget * zoomDelta); if (clamped == ScaleTarget) return; if (Math.Abs(zoomDelta - 1) < 0.001f) return; var zoom = zoomDelta * Vector2.One; if (IsCurveCanvas) { if (ImGui.GetIO().KeyAlt) { zoom.X = 1; } else if (ImGui.GetIO().KeyShift) { zoom.Y = 1; } } ScaleTarget *= zoom; if (Math.Abs(zoomDelta) > 0.1f) zoomed = true; var focusCenterOnCanvas = InverseTransformPositionFloat(position); ScrollTarget += (focusCenterOnCanvas - ScrollTarget) * (zoom - Vector2.One) / zoom; } private void DrawCanvasDebugInfos(Vector2 mousePos, bool directlyHovered, bool isInteractable, bool isAlreadyActive) { var focusCenterOnCanvas = InverseTransformPositionFloat(mousePos); var dl = ImGui.GetForegroundDrawList(); var wp = ImGui.GetWindowPos(); if (directlyHovered) { var focusOnScreen = TransformPosition(focusCenterOnCanvas); dl.AddCircle(focusOnScreen, 30, Color.Green.Fade(0.2f)); dl.AddText(focusOnScreen + new Vector2(10, -10), Color.Green, $"{focusCenterOnCanvas.X:0.0} {focusCenterOnCanvas.Y:0.0} "); dl.AddRect(wp, wp + ImGui.GetWindowSize(), Color.Green.Fade(0.4f)); } dl.AddRectFilled(wp, wp + new Vector2(200, 100), UiColors.WindowBackground.Fade(0.4f)); dl.AddText(wp + new Vector2(160, 0), Color.Green, $"hovered:{directlyHovered}\ninteractable:{isInteractable}\nactive:{isAlreadyActive}"); dl.AddText(wp + new Vector2(0, 0), Color.Green, $"Scale: {ScaleTarget.X:0.0} {ScaleTarget.Y:0.0} "); dl.AddText(wp + new Vector2(0, 16), Color.Green, $"Scroll: {ScrollTarget.X:0.0} {ScrollTarget.Y:0.0} "); dl.AddText(wp + new Vector2(0, 32), Color.Green, $"Canvas: {focusCenterOnCanvas.X:0.0} {focusCenterOnCanvas.Y:0.0} "); var hovered = ImGui.IsWindowHovered() ? "hovered" : ""; var hoveredChild = ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows) ? "hoveredChildWindows" : ""; dl.AddText(wp + new Vector2(0, 48), Color.Green, $"{hovered} {hoveredChild}"); var focused = ImGui.IsWindowFocused() ? "focused" : ""; var focusedChild = ImGui.IsWindowFocused(ImGuiFocusedFlags.ChildWindows) ? "focusedChildWindows" : ""; dl.AddText(wp + new Vector2(0, 64), Color.Green, $"{focused} {focusedChild}"); // // Test window content region: // var paddingForFocusBorder = 1; // var pos = ImGui.GetCursorStartPos() + ImGui.GetWindowPos() + paddingForFocusBorder * Vector2.One; // var size = (ImGui.GetWindowSize() - ImGui.GetStyle().WindowPadding) - ImGui.GetCursorStartPos() - paddingForFocusBorder * 2 * Vector2.One; // dl.AddRect(pos, pos + size, Color.Red); } private bool IsCurveCanvas => Scale.Y < 0; private static float ComputeZoomDeltaFromMouseWheel(float ioMouseWheel) { const float zoomSpeed = 1.2f; var zoomSum = 1f; if (ioMouseWheel < 0.0f) { for (var zoom = ioMouseWheel; zoom < 0.0f; zoom += 1.0f) { zoomSum /= zoomSpeed; } } if (ioMouseWheel > 0.0f) { for (var zoom = ioMouseWheel; zoom > 0.0f; zoom -= 1.0f) { zoomSum *= zoomSpeed; } } zoomSum = zoomSum.Clamp(0.02f, 100f); return zoomSum; } private Vector2 _mousePosWhenDragZoomStarted; private bool _isDragZooming; private float _lastZoomDelta; private void ZoomWithDrag() { var mouseButton = UserSettings.Config.MiddleMouseButtonZooms ? ImGuiMouseButton.Middle : ImGuiMouseButton.Right; var hotkeysMatch = UserSettings.Config.MiddleMouseButtonZooms || ImGui.GetIO().KeyShift; if (ImGui.IsMouseClicked(mouseButton) && hotkeysMatch) { _isDragZooming = true; _lastZoomDelta = 1; _mousePosWhenDragZoomStarted = ImGui.GetMousePos(); } if (ImGui.IsMouseReleased(mouseButton)) _isDragZooming = false; if (!ImGui.IsMouseDragging(mouseButton, 0)) return; var delta = ImGui.GetMousePos() - _mousePosWhenDragZoomStarted; var deltaMax = Math.Abs(delta.X) > Math.Abs(delta.Y) ? -delta.X : delta.Y; if (!_isDragZooming) return; var f = (float)Math.Pow(1.13f, -deltaMax / 40f); var delta2 = f / _lastZoomDelta; ApplyZoomDelta(_mousePosWhenDragZoomStarted, delta2, out var zoomed); // FIXME: unclear what this does _lastZoomDelta = f; } internal enum FillModes { FillWindow, FillAvailableContentRegion, } internal FillModes FillMode = FillModes.FillWindow; public bool EnableParentZoom { get; set; } = true; protected virtual ScalableCanvas? Parent { get; } public readonly record struct InteractionState(bool UserPannedCanvas, bool UserZoomedCanvas, MouseState MouseState); // public static string PrintInteractionState(in InteractionState state) // { // var sb = new StringBuilder(); // sb.Append("Panned: "); // sb.Append(state.UserPannedCanvas ? "Yes" : "No"); // sb.Append(" Zoomed: "); // sb.Append(state.UserZoomedCanvas ? "Yes" : "No"); // sb.Append(" Mouse: "); // sb.Append(state.MouseState.Position); // var str = sb.ToString(); // Log.Debug(str); // return str; // } } public readonly record struct MouseState(Vector2 Position, Vector2 Delta, float ScrollWheel);