diff --git a/PhotonUI/Animation/AnimationHandle.cs b/PhotonUI/Animation/AnimationHandle.cs new file mode 100644 index 0000000..636bc05 --- /dev/null +++ b/PhotonUI/Animation/AnimationHandle.cs @@ -0,0 +1,118 @@ +using PhotonUI.Events.Framework; + +namespace PhotonUI.Animations +{ + public enum AnimationState + { + Ready, + Running, + Paused, + Stopped, + Completed, + Canceled, + Invalid + } + + public sealed class AnimationHandle(AnimationBase inner) + { + public event EventHandler? StateChanged; + + private readonly AnimationBase inner = inner; + private AnimationState state = AnimationState.Ready; + private bool isValid = true; + + public AnimationState State => this.state; + public bool IsValid => this.isValid; + public bool IsComplete + { + get + { + this.EnsureValid(); + + return this.state == AnimationState.Completed; + } + } + + public void Invalidate() + => this.isValid = false; + public void EnsureValid() + { + if (!this.isValid) + throw new InvalidOperationException("Animation handle is no longer valid."); + } + + public void Start() + { + this.EnsureValid(); + + if (this.state == AnimationState.Ready || this.state == AnimationState.Paused) + { + this.inner.Start(); + this.state = AnimationState.Running; + + this.RaiseStateChanged(); + } + } + public void Update() + { + this.EnsureValid(); + + if (this.state == AnimationState.Running) + { + this.inner.Update(); + + if (this.inner.IsComplete) + { + this.state = AnimationState.Completed; + + this.RaiseStateChanged(); + } + } + } + + public void Pause() + { + this.EnsureValid(); + + if (this.state == AnimationState.Running) + { + this.state = AnimationState.Paused; + + this.RaiseStateChanged(); + } + } + public void Resume() + { + this.EnsureValid(); + + if (this.state == AnimationState.Paused) + { + this.state = AnimationState.Running; + + this.RaiseStateChanged(); + } + } + public void Stop() + { + this.EnsureValid(); + + if (this.state == AnimationState.Running || this.state == AnimationState.Paused) + { + this.state = AnimationState.Stopped; + + this.RaiseStateChanged(); + } + } + + public void Cancel() + { + this.EnsureValid(); + this.state = AnimationState.Canceled; + this.Invalidate(); + this.RaiseStateChanged(); + } + + private void RaiseStateChanged() + => StateChanged?.Invoke(this, new AnimationEventArgs(this, this.state)); + } +} \ No newline at end of file diff --git a/PhotonUI/Animation/AnimationNode.cs b/PhotonUI/Animation/AnimationNode.cs new file mode 100644 index 0000000..435a71b --- /dev/null +++ b/PhotonUI/Animation/AnimationNode.cs @@ -0,0 +1,32 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using PhotonUI.Animations.AnimationNodes; +using PhotonUI.Controls; + +namespace PhotonUI.Animations +{ + public abstract partial class AnimationBase : ObservableObject + { + public abstract void Start(); + public abstract void Update(); + public abstract bool IsComplete { get; } + + public AnimationBase Then(AnimationBase next) + => new SequenceAnimation(this, next); + public AnimationBase Group(AnimationBase other) + => new GroupAnimation(this, other); + public AnimationBase Wait(TimeSpan delay) + => new SequenceAnimation(this, new WaitAnimation(delay)); + public AnimationBase OnComplete(Action callback) + => new SequenceAnimation(this, new CallbackAnimation(callback)); + + public AnimationHandle Enqueue(Window window) + { + AnimationHandle handle = window.AnimationEnqueue(this); + + if (handle.State == AnimationState.Ready) + handle.Start(); + + return handle; + } + } +} \ No newline at end of file diff --git a/PhotonUI/Animation/Nodes/CallbackAnimation.cs b/PhotonUI/Animation/Nodes/CallbackAnimation.cs new file mode 100644 index 0000000..facd80a --- /dev/null +++ b/PhotonUI/Animation/Nodes/CallbackAnimation.cs @@ -0,0 +1,20 @@ +namespace PhotonUI.Animations.AnimationNodes +{ + public partial class CallbackAnimation(Action callback) : AnimationBase + { + private readonly Action callback = callback; + private bool fired; + + public override void Start() + { + if (!this.fired) + { + this.fired = true; + this.callback(); + } + } + + public override void Update() { } + public override bool IsComplete => this.fired; + } +} \ No newline at end of file diff --git a/PhotonUI/Animation/Nodes/GroupAnimation.cs b/PhotonUI/Animation/Nodes/GroupAnimation.cs new file mode 100644 index 0000000..83f9fa3 --- /dev/null +++ b/PhotonUI/Animation/Nodes/GroupAnimation.cs @@ -0,0 +1,11 @@ +namespace PhotonUI.Animations.AnimationNodes +{ + public partial class GroupAnimation(params AnimationBase[] animations) : AnimationBase + { + private readonly List animations = [.. animations]; + + public override void Start() { foreach (AnimationBase a in this.animations) a.Start(); } + public override void Update() { foreach (AnimationBase a in this.animations) a.Update(); } + public override bool IsComplete => this.animations.All(a => a.IsComplete); + } +} \ No newline at end of file diff --git a/PhotonUI/Animation/Nodes/PropertyAnimation.cs b/PhotonUI/Animation/Nodes/PropertyAnimation.cs new file mode 100644 index 0000000..a0c17fa --- /dev/null +++ b/PhotonUI/Animation/Nodes/PropertyAnimation.cs @@ -0,0 +1,63 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using PhotonUI.Controls; +using PhotonUI.Interfaces.Services; +using PhotonUI.Services; + +namespace PhotonUI.Animations +{ + public partial class PropertyAnimation(IInterpolatorService lerpService) : AnimationBase + where TTarget : Control + { + private readonly IInterpolatorService lerpService = lerpService; + private readonly IInterpolator interpolator = lerpService.Get(); + + private TProp? start; + private TProp? end; + private TimeSpan duration; + private Func easing = t => t; + private DateTime startTime; + + private bool loop; + private Func? nextTargetFactory; + private TimeSpan epsilon = TimeSpan.FromMilliseconds(1); + + [ObservableProperty] + private TProp? value; + + public override bool IsComplete => !this.loop && (DateTime.UtcNow - this.startTime) >= this.duration; + + public PropertyAnimation From(TProp start) { this.start = start; return this; } + public PropertyAnimation To(TProp end) { this.end = end; return this; } + public PropertyAnimation Over(TimeSpan duration) { this.duration = duration; return this; } + public PropertyAnimation WithEasing(Func easing) { this.easing = easing; return this; } + public PropertyAnimation Loop(Func nextFactory) { this.loop = true; this.nextTargetFactory = nextFactory; return this; } + public PropertyAnimation WithLoopOverlap(TimeSpan overlap) { this.epsilon = overlap; return this; } + + public override void Start() => this.startTime = DateTime.UtcNow; + public override void Update() + { + if (this.duration == TimeSpan.Zero || this.start is null || this.end is null) return; + + float elapsedMs = (float)(DateTime.UtcNow - this.startTime).TotalMilliseconds; + float durationMs = (float)this.duration.TotalMilliseconds; + + if (this.loop && elapsedMs >= durationMs - (float)this.epsilon.TotalMilliseconds) + { + this.Value = this.end; + TProp current = this.end!; + TProp? next = this.nextTargetFactory!(current); + + this.start = current; + this.end = next; + this.startTime = DateTime.UtcNow; + + elapsedMs = 0f; + } + + float progress = Math.Clamp(elapsedMs / durationMs, 0f, 1f); + float eased = this.easing(progress); + + this.Value = this.interpolator.Lerp(this.start!, this.end!, eased); + } + } +} \ No newline at end of file diff --git a/PhotonUI/Animation/Nodes/SequenceAnimation.cs b/PhotonUI/Animation/Nodes/SequenceAnimation.cs new file mode 100644 index 0000000..745faae --- /dev/null +++ b/PhotonUI/Animation/Nodes/SequenceAnimation.cs @@ -0,0 +1,22 @@ +namespace PhotonUI.Animations.AnimationNodes +{ + public partial class SequenceAnimation(params AnimationBase[] animations) : AnimationBase + { + private readonly Queue queue = new(animations); + private AnimationBase? current; + + public override void Start() => this.Advance(); + public override void Update() + { + this.current?.Update(); + if (this.current?.IsComplete == true) this.Advance(); + } + public override bool IsComplete => this.current == null; + + private void Advance() + { + this.current = this.queue.Count > 0 ? this.queue.Dequeue() : null; + this.current?.Start(); + } + } +} \ No newline at end of file diff --git a/PhotonUI/Animation/Nodes/WaitAnimation.cs b/PhotonUI/Animation/Nodes/WaitAnimation.cs new file mode 100644 index 0000000..3af246a --- /dev/null +++ b/PhotonUI/Animation/Nodes/WaitAnimation.cs @@ -0,0 +1,12 @@ +namespace PhotonUI.Animations.AnimationNodes +{ + public partial class WaitAnimation(TimeSpan duration) : AnimationBase + { + private readonly TimeSpan duration = duration; + private DateTime start; + + public override void Start() => this.start = DateTime.UtcNow; + public override void Update() { } + public override bool IsComplete => (DateTime.UtcNow - this.start) >= this.duration; + } +} \ No newline at end of file diff --git a/PhotonUI/Components/AnimationBuilder.cs b/PhotonUI/Components/AnimationBuilder.cs new file mode 100644 index 0000000..73ed9d5 --- /dev/null +++ b/PhotonUI/Components/AnimationBuilder.cs @@ -0,0 +1,27 @@ +using PhotonUI.Animations; +using PhotonUI.Controls; +using PhotonUI.Interfaces.Services; +using System.Linq.Expressions; +using System.Reflection; + +namespace PhotonUI.Components +{ + public class AnimationBuilder(IInterpolatorService lerpService, IBindingService bindingService) : IAnimationBuilder + { + private readonly IInterpolatorService lerpService = lerpService; + private readonly IBindingService bindingService = bindingService; + + public PropertyAnimation BuildPropertyAnimation(TTarget target, Expression> selector) + where TTarget : Control + { + if (selector.Body is not MemberExpression member || member.Member is not PropertyInfo propInfo) + throw new InvalidOperationException("Selector must be a property expression"); + + PropertyAnimation anim = new(this.lerpService); + + this.bindingService.Bind(target, propInfo.Name, anim, nameof(anim.Value), twoWay: false); + + return anim; + } + } +} \ No newline at end of file diff --git a/PhotonUI/Controls/Composites/UserWindow.cs b/PhotonUI/Controls/Composites/UserWindow.cs index 1dcc5fb..aa3c0f2 100644 --- a/PhotonUI/Controls/Composites/UserWindow.cs +++ b/PhotonUI/Controls/Composites/UserWindow.cs @@ -2,7 +2,7 @@ namespace PhotonUI.Controls.Composites { - public partial class UserWindow(IServiceProvider serviceProvider, IBindingService bindingService, IKeyBindingService keyBindingService) - : Window(serviceProvider, bindingService, keyBindingService) + public partial class UserWindow(IServiceProvider serviceProvider, IBindingService bindingService, IKeyBindingService keyBindingService, IAnimationBuilder animationBulder) + : Window(serviceProvider, bindingService, keyBindingService, animationBulder) { } } \ No newline at end of file diff --git a/PhotonUI/Controls/Window.cs b/PhotonUI/Controls/Window.cs index c11a23f..d6d1873 100644 --- a/PhotonUI/Controls/Window.cs +++ b/PhotonUI/Controls/Window.cs @@ -1,9 +1,11 @@ -using PhotonUI.Events; +using PhotonUI.Animations; +using PhotonUI.Events; using PhotonUI.Events.Framework; using PhotonUI.Events.Platform; using PhotonUI.Extensions; using PhotonUI.Interfaces.Services; using SDL3; +using System.Linq.Expressions; namespace PhotonUI.Controls { @@ -15,9 +17,10 @@ public enum WindowMode } public record WindowTabStopEntry(int Stop, Control Control, int InsertionOrder); - public partial class Window(IServiceProvider serviceProvider, IBindingService bindingService, IKeyBindingService keyBindingService) + public partial class Window(IServiceProvider serviceProvider, IBindingService bindingService, IKeyBindingService keyBindingService, IAnimationBuilder animationBulder) : Presenter(serviceProvider, bindingService, keyBindingService) { + protected IAnimationBuilder AnimationBulder = animationBulder; protected WindowMode WindowMode = WindowMode.Tangible; protected IntPtr WindowBackTexture; @@ -25,6 +28,7 @@ public partial class Window(IServiceProvider serviceProvider, IBindingService bi protected Control? HoveredControl; protected Control? KeyboardCapturedControl; protected Control? MouseCapturedControl; + protected readonly List ActiveAnimations = []; protected readonly List TabStops = []; protected int TabStopIndex = -1; @@ -75,6 +79,7 @@ public virtual void Initialize() public virtual void Tick() { this.ApplyTick(); + this.ApplyAnimationRequests(); this.ApplyIntrinsicRequests(); this.ApplyMeasureRequests(); this.ApplyArrangeRequests(); @@ -326,6 +331,24 @@ public virtual void SetRenderer(IntPtr renderer) this.SetWindowSize((int)this.DrawRect.W, (int)this.DrawRect.H); } + public PropertyAnimation Animate(TTarget target, Expression> selector) + where TTarget : Control + { + return this.AnimationBulder.BuildPropertyAnimation(target, selector); + } + public AnimationHandle AnimationEnqueue(AnimationBase animation) + { + AnimationHandle handle = new(animation); + + this.ActiveAnimations.Add(handle); + + if (handle.State == AnimationState.Ready) + handle.Start(); + + return handle; + } + public void CancelAllAnimations() => this.ActiveAnimations.Clear(); + public virtual void CaptureMouse(Control control) { ArgumentNullException.ThrowIfNull(control, nameof(control)); @@ -405,6 +428,27 @@ protected void ApplyTick() return true; }); } + protected void ApplyAnimationRequests() + { + if (this.ActiveAnimations.Count == 0) return; + + foreach (AnimationHandle? handle in this.ActiveAnimations.ToList()) + { + if (!handle.IsValid || + handle.State == AnimationState.Invalid || + handle.State == AnimationState.Completed || + handle.State == AnimationState.Stopped || + handle.State == AnimationState.Canceled) + { + this.ActiveAnimations.Remove(handle); + handle.Invalidate(); + continue; + } + + if (handle.State == AnimationState.Running) + handle.Update(); + } + } protected void ApplyIntrinsicRequests() { this.TunnelControls(control => diff --git a/PhotonUI/Events/Framework/AnimationEventArgs.cs b/PhotonUI/Events/Framework/AnimationEventArgs.cs new file mode 100644 index 0000000..6551ede --- /dev/null +++ b/PhotonUI/Events/Framework/AnimationEventArgs.cs @@ -0,0 +1,10 @@ +using PhotonUI.Animations; + +namespace PhotonUI.Events.Framework +{ + public class AnimationEventArgs(AnimationHandle handle, AnimationState state) : FrameworkEventArgs + { + public AnimationHandle Handle { get; } = handle; + public AnimationState State { get; } = state; + } +} \ No newline at end of file diff --git a/PhotonUI/Interfaces/Services/IAnimationBuilder.cs b/PhotonUI/Interfaces/Services/IAnimationBuilder.cs new file mode 100644 index 0000000..b71fc5f --- /dev/null +++ b/PhotonUI/Interfaces/Services/IAnimationBuilder.cs @@ -0,0 +1,11 @@ +using PhotonUI.Animations; +using PhotonUI.Controls; +using System.Linq.Expressions; + +namespace PhotonUI.Interfaces.Services +{ + public interface IAnimationBuilder + { + PropertyAnimation BuildPropertyAnimation(TTarget target, Expression> selector) where TTarget : Control; + } +} \ No newline at end of file diff --git a/PhotonUI/Interfaces/Services/IInterpolatorService.cs b/PhotonUI/Interfaces/Services/IInterpolatorService.cs new file mode 100644 index 0000000..d4b5d99 --- /dev/null +++ b/PhotonUI/Interfaces/Services/IInterpolatorService.cs @@ -0,0 +1,10 @@ +using PhotonUI.Services; + +namespace PhotonUI.Interfaces.Services +{ + public interface IInterpolatorService + { + IInterpolator Get(); + void Register(IInterpolator interpolator); + } +} \ No newline at end of file diff --git a/PhotonUI/Photon.cs b/PhotonUI/Photon.cs index 04dc35b..10bd545 100644 --- a/PhotonUI/Photon.cs +++ b/PhotonUI/Photon.cs @@ -21,6 +21,18 @@ public static void EnsureRootWindow(Control control) if (control.Window is null) throw new InvalidOperationException($"Control '{control.Name}' has no RootWindow."); } + public static Window GetWindow(Control control) + { + Control? current = control; + + while (current.Parent != null) + current = current.Parent; + + if (current is Window window) + return window; + + throw new InvalidOperationException("Window not found"); + } public static void InvalidateRenderChain(Control control) { if (!control.IsInitialized) return; @@ -61,6 +73,37 @@ public static SDL.FRect ScaleRect(SDL.FRect rect, Vector2 scale, SDL.FPoint anch return new SDL.FRect { X = newX, Y = newY, W = newW, H = newH }; } + public static SDL.FRect RotateRect(SDL.FRect rect, float angle, SDL.FPoint anchor) + { + Vector2 norm = new(rect.X + rect.W * anchor.X, rect.Y + rect.H * anchor.Y); + + Vector2[] corners = + [ + new Vector2(rect.X, rect.Y), + new Vector2(rect.X + rect.W, rect.Y), + new Vector2(rect.X + rect.W, rect.Y + rect.H), + new Vector2(rect.X, rect.Y + rect.H) + ]; + + Vector2[] rotated = [.. corners.Select(c => + { + float dx = c.X - norm.X; + float dy = c.Y - norm.Y; + float cos = MathF.Cos(angle); + float sin = MathF.Sin(angle); + return new Vector2( + dx * cos - dy * sin + norm.X, + dx * sin + dy * cos + norm.Y + ); + })]; + + float minX = rotated.Min(p => p.X); + float maxX = rotated.Max(p => p.X); + float minY = rotated.Min(p => p.Y); + float maxY = rotated.Max(p => p.Y); + + return new SDL.FRect { X = minX, Y = minY, W = maxX - minX, H = maxY - minY }; + } public static Size GetScaledSize(Size controlSize, Size contentSize, StretchProperties props) { @@ -276,6 +319,13 @@ public static bool HitTest(SDL.FRect bounds, float px, float py) return px >= bounds.X && px < bounds.X + bounds.W && py >= bounds.Y && py < bounds.Y + bounds.H; } + public static bool ControlHitTest(Control control, float px, float py) + { + if (!control.IsVisible || !control.IsHitTestVisible) + return false; + + return HitTest(control.DrawRect, px, py); + } public static bool AncestorHitTest(Control control, float px, float py) { Control? parent = control.Parent; @@ -436,6 +486,21 @@ public static float GetEdgeScrollVertical(float currentOffset, float mouseLocalY #region Photon: Control Layout Helpers + public static float GetMeasuredStackWidth(IReadOnlyList children) + { + float total = 0f; + foreach (Control child in children) + if (child != null) total += child.DrawRect.W + child.MarginExtent.Horizontal; + return total; + } + public static float GetMeasuredStackHeight(IReadOnlyList children) + { + float total = 0f; + foreach (Control child in children) + if (child != null) total += child.DrawRect.H + child.MarginExtent.Vertical; + return total; + } + public static float ApplyHorizontalScroll(Control control, float scrollX, SDL.FRect viewport) { SDL.FRect scrolled = control.DrawRect; @@ -883,6 +948,20 @@ public static void DrawControlTexture(T control, IntPtr texture, SDL.FRect de SDL.SetTextureAlphaMod(texture, originalAlpha); } + public static void DrawControlTextureRotated(T control, IntPtr texture, SDL.FRect destination, double angle, SDL.FPoint center, SDL.FlipMode flip, SDL.Rect? clipRect = null) where T : Control, IControlProperties + { + EnsureRootWindow(control); + + SDL.GetTextureAlphaMod(texture, out byte originalAlpha); + + byte newAlpha = (byte)(originalAlpha * control.Opacity); + + SDL.SetTextureAlphaMod(texture, newAlpha); + + DrawTextureRotated(control.Window!, texture, null, destination, angle, center, flip, control.Window!.BackTexture, clipRect); + + SDL.SetTextureAlphaMod(texture, originalAlpha); + } public static void DrawControlTextureRotated(T control, IntPtr texture, SDL.FRect destination, SDL.FRect? sourceRect, double angle, SDL.FPoint center, SDL.FlipMode flip, SDL.Rect? clipRect = null) where T : Control, IControlProperties { EnsureRootWindow(control); diff --git a/PhotonUI/Services/InterpolatorService.cs b/PhotonUI/Services/InterpolatorService.cs new file mode 100644 index 0000000..6c5f817 --- /dev/null +++ b/PhotonUI/Services/InterpolatorService.cs @@ -0,0 +1,45 @@ +using PhotonUI.Interfaces.Services; + +namespace PhotonUI.Services +{ + public interface IInterpolator { } + public interface IInterpolator : IInterpolator + { + T Lerp(T start, T end, float progress); + } + + public class InterpolatorService : IInterpolatorService + { + private readonly Dictionary map = []; + + public InterpolatorService(IEnumerable interpolators) + { + foreach (object interpolator in interpolators) + { + Type? iface = + interpolator + .GetType() + .GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IInterpolator<>)); + + if (iface != null) + { + Type targetType = iface.GetGenericArguments()[0]; + + this.map[targetType] = interpolator; + } + } + } + + public void Register(IInterpolator interpolator) + => this.map[typeof(T)] = interpolator; + + public IInterpolator Get() + { + if (this.map.TryGetValue(typeof(T), out object? impl)) + return (IInterpolator)impl; + + throw new InvalidOperationException($"No interpolator registered for {typeof(T)}"); + } + } +} \ No newline at end of file diff --git a/PhotonUI/Services/Interpolators/FloatInterpolator.cs b/PhotonUI/Services/Interpolators/FloatInterpolator.cs new file mode 100644 index 0000000..962c6df --- /dev/null +++ b/PhotonUI/Services/Interpolators/FloatInterpolator.cs @@ -0,0 +1,8 @@ +namespace PhotonUI.Services.Interpolators +{ + public sealed class FloatInterpolator : IInterpolator + { + public float Lerp(float start, float end, float progress) + => start + (end - start) * progress; + } +} \ No newline at end of file diff --git a/PhotonUI/Services/Interpolators/IntInterpolator.cs b/PhotonUI/Services/Interpolators/IntInterpolator.cs new file mode 100644 index 0000000..46fcc0b --- /dev/null +++ b/PhotonUI/Services/Interpolators/IntInterpolator.cs @@ -0,0 +1,8 @@ +namespace PhotonUI.Services.Interpolators +{ + public sealed class IntInterpolator : IInterpolator + { + public int Lerp(int start, int end, float progress) + => (int)(start + (end - start) * progress); + } +} \ No newline at end of file diff --git a/PhotonUI/Services/Interpolators/SDLColorInterpolator.cs b/PhotonUI/Services/Interpolators/SDLColorInterpolator.cs new file mode 100644 index 0000000..95676a3 --- /dev/null +++ b/PhotonUI/Services/Interpolators/SDLColorInterpolator.cs @@ -0,0 +1,20 @@ +using SDL3; + +namespace PhotonUI.Services.Interpolators +{ + public sealed class SDLColorInterpolator : IInterpolator + { + public SDL.Color Lerp(SDL.Color start, SDL.Color end, float progress) + { + progress = Math.Clamp(progress, 0f, 1f); + + return new SDL.Color + { + R = (byte)(start.R + (end.R - start.R) * progress), + G = (byte)(start.G + (end.G - start.G) * progress), + B = (byte)(start.B + (end.B - start.B) * progress), + A = (byte)(start.A + (end.A - start.A) * progress) + }; + } + } +} \ No newline at end of file