diff --git a/Directory.Packages.props b/Directory.Packages.props index 3a5170168..1a43ed17a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,6 +35,7 @@ + diff --git a/src/Beutl.Controls/PropertyEditors/ExpressionEditorFlyoutPresenter.cs b/src/Beutl.Controls/PropertyEditors/ExpressionEditorFlyoutPresenter.cs new file mode 100644 index 000000000..041f4d79e --- /dev/null +++ b/src/Beutl.Controls/PropertyEditors/ExpressionEditorFlyoutPresenter.cs @@ -0,0 +1,77 @@ +#nullable enable + +using System.Reactive.Disposables; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; + +using Beutl.Reactive; + +namespace Beutl.Controls.PropertyEditors; + +public class ExpressionEditorFlyoutPresenter : DraggablePickerFlyoutPresenter +{ + public static readonly StyledProperty ExpressionTextProperty = + AvaloniaProperty.Register(nameof(ExpressionText)); + + public static readonly StyledProperty ErrorMessageProperty = + AvaloniaProperty.Register(nameof(ErrorMessage)); + + private readonly CompositeDisposable _disposables = []; + private const string HasErrorPseudoClass = ":has-error"; + private const string HelpTabPseudoClass = ":help-tab"; + + private RadioButton? _inputTabButton; + private RadioButton? _helpTabButton; + + public string? ExpressionText + { + get => GetValue(ExpressionTextProperty); + set => SetValue(ExpressionTextProperty, value); + } + + public string? ErrorMessage + { + get => GetValue(ErrorMessageProperty); + set => SetValue(ErrorMessageProperty, value); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + _disposables.Clear(); + base.OnApplyTemplate(e); + + _inputTabButton = e.NameScope.Find("InputTabButton"); + _helpTabButton = e.NameScope.Find("HelpTabButton"); + + _inputTabButton?.AddDisposableHandler(ToggleButton.IsCheckedChangedEvent, OnTabButtonIsCheckedChanged) + .DisposeWith(_disposables); + + _helpTabButton?.AddDisposableHandler(ToggleButton.IsCheckedChangedEvent, OnTabButtonIsCheckedChanged) + .DisposeWith(_disposables); + + if (_inputTabButton != null) + { + _inputTabButton.IsChecked = true; + } + } + + private void OnTabButtonIsCheckedChanged(object? sender, RoutedEventArgs e) + { + if (_helpTabButton != null) + { + PseudoClasses.Set(HelpTabPseudoClass, _helpTabButton.IsChecked == true); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == ErrorMessageProperty) + { + PseudoClasses.Set(HasErrorPseudoClass, !string.IsNullOrWhiteSpace(ErrorMessage)); + } + } +} diff --git a/src/Beutl.Controls/Styles.axaml b/src/Beutl.Controls/Styles.axaml index b295cecae..1a3a77fc6 100644 --- a/src/Beutl.Controls/Styles.axaml +++ b/src/Beutl.Controls/Styles.axaml @@ -50,6 +50,7 @@ + diff --git a/src/Beutl.Controls/Styling/PropertyEditors/ExpressionEditorFlyoutPresenter.axaml b/src/Beutl.Controls/Styling/PropertyEditors/ExpressionEditorFlyoutPresenter.axaml new file mode 100644 index 000000000..34324f534 --- /dev/null +++ b/src/Beutl.Controls/Styling/PropertyEditors/ExpressionEditorFlyoutPresenter.axaml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Beutl.Core/CoreObjectExtensions.cs b/src/Beutl.Core/CoreObjectExtensions.cs index 7ca8c48bc..dacc354cd 100644 --- a/src/Beutl.Core/CoreObjectExtensions.cs +++ b/src/Beutl.Core/CoreObjectExtensions.cs @@ -55,7 +55,7 @@ public static IObservable GetObservable(this ICoreObject obj, CoreProperty foreach (CoreProperty prop in props) { object? inner = obj.GetValue(prop); - if (inner != null && hashSet.Add(inner)) + if (inner != null && !hashSet.Contains(inner)) { if (predicate(inner)) { @@ -65,6 +65,8 @@ public static IObservable GetObservable(this ICoreObject obj, CoreProperty { return match; } + + hashSet.Add(inner); } } @@ -75,7 +77,7 @@ public static IObservable GetObservable(this ICoreObject obj, CoreProperty { foreach (IHierarchical item in hierarchical.HierarchicalChildren) { - if (hashSet.Add(item)) + if (!hashSet.Contains(item)) { if (predicate(item)) { @@ -85,6 +87,8 @@ public static IObservable GetObservable(this ICoreObject obj, CoreProperty { return match; } + + hashSet.Add(item); } } diff --git a/src/Beutl.Core/Reactive/ObservableExtensions.cs b/src/Beutl.Core/Reactive/ObservableExtensions.cs new file mode 100644 index 000000000..2ae604643 --- /dev/null +++ b/src/Beutl.Core/Reactive/ObservableExtensions.cs @@ -0,0 +1,19 @@ +using System.Reactive.Disposables; +using System.Reactive.Linq; + +namespace Beutl.Reactive; + +public static class ObservableExtensions +{ + extension(Observable) + { + public static IObservable ReturnThenNever(T value) + { + return Observable.Create(observer => + { + observer.OnNext(value); + return Disposable.Empty; + }); + } + } +} diff --git a/src/Beutl.Engine/Beutl.Engine.csproj b/src/Beutl.Engine/Beutl.Engine.csproj index 07d14aa4a..240cf500f 100644 --- a/src/Beutl.Engine/Beutl.Engine.csproj +++ b/src/Beutl.Engine/Beutl.Engine.csproj @@ -34,5 +34,6 @@ + diff --git a/src/Beutl.Engine/Converters/ColorConverter.cs b/src/Beutl.Engine/Converters/ColorConverter.cs index c34b48935..f71d2e0b7 100644 --- a/src/Beutl.Engine/Converters/ColorConverter.cs +++ b/src/Beutl.Engine/Converters/ColorConverter.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using Beutl.Animation; +using Beutl.Graphics.Rendering; using Beutl.Media; namespace Beutl.Converters; @@ -111,7 +112,7 @@ public override bool CanConvertFrom(ITypeDescriptorContext? context, Type source } else if (value is SolidColorBrush solidColorBrush) { - return solidColorBrush.Color.GetValue(TimeSpan.Zero); + return solidColorBrush.Color.GetValue(RenderContext.Default); } else if (value is SolidColorBrush.Resource solidColorBrushResource) { diff --git a/src/Beutl.Engine/Engine/AnimatableProperty.cs b/src/Beutl.Engine/Engine/AnimatableProperty.cs index ddcfea2f8..74ddf09b3 100644 --- a/src/Beutl.Engine/Engine/AnimatableProperty.cs +++ b/src/Beutl.Engine/Engine/AnimatableProperty.cs @@ -2,6 +2,8 @@ using System.Diagnostics; using System.Reflection; using Beutl.Animation; +using Beutl.Engine.Expressions; +using Beutl.Graphics.Rendering; using Beutl.Serialization; using Beutl.Validation; using ValidationContext = Beutl.Validation.ValidationContext; @@ -12,10 +14,12 @@ public class AnimatableProperty : IProperty { private T _currentValue; private IAnimation? _animation; + private IExpression? _expression; private IValidator? _validator; private PropertyInfo? _propertyInfo; private string _name; private EngineObject? _owner; + private PropertyLookup? _propertyLookup; public AnimatableProperty(T defaultValue, IValidator? validator = null) { @@ -101,12 +105,30 @@ public IAnimation? Animation public bool HasLocalValue { get; private set; } + public bool HasExpression => _expression != null; + + public IExpression? Expression + { + get => _expression; + set + { + if (_expression != value) + { + _expression = value; + ExpressionChanged?.Invoke(_expression); + Edited?.Invoke(this, EventArgs.Empty); + } + } + } + public event EventHandler>? ValueChanged; public event EventHandler? Edited; public event Action?>? AnimationChanged; + public event Action?>? ExpressionChanged; + public void operator <<= (T value) { CurrentValue = value; @@ -117,34 +139,50 @@ private void OnChildEdited(object? sender, EventArgs e) Edited?.Invoke(sender, e); } - public T GetValue(TimeSpan time) + public T GetValue(RenderContext context) { - try - { - T value; + T value; - // アニメーション値を優先 - if (_animation != null) + // 式を最優先 + if (_expression != null) + { + _propertyLookup ??= new PropertyLookup(_owner?.FindHierarchicalRoot() as ICoreObject ?? BeutlApplication.Current); + if (context is ExpressionContext expressionContext) + { + if (expressionContext.IsEvaluating(this)) + return DefaultValue; + } + else { - value = _animation.GetAnimatedValue(time) ?? _currentValue; + expressionContext = new ExpressionContext(context.Time, this, _propertyLookup); + } - // アニメーション値もバリデーション + expressionContext.BeginEvaluation(this); + try + { + value = _expression.Evaluate(expressionContext); value = ValidateAndCoerce(value); } - else + finally { - // アニメーションがない場合は現在値 - value = _currentValue; + expressionContext.EndEvaluation(this); } + } + // アニメーション値を次に優先 + else if (_animation != null) + { + value = _animation.GetAnimatedValue(context.Time) ?? _currentValue; - return value; + // アニメーション値もバリデーション + value = ValidateAndCoerce(value); } - catch (Exception ex) + else { - // エラー時は安全なデフォルト値を返す - Debug.WriteLine($"Error getting value for property '{Name}': {ex.Message}"); - return DefaultValue; + // アニメーションがない場合は現在値 + value = _currentValue; } + + return value; } public void SetPropertyInfo(PropertyInfo propertyInfo) @@ -177,6 +215,7 @@ public void SetValidator(IValidator validator) public void SetOwnerObject(EngineObject? owner) { if (_owner == owner) return; + _propertyLookup = null; if (owner is IModifiableHierarchical ownerHierarchical) { @@ -228,6 +267,7 @@ public void ResetToDefault() CurrentValue = DefaultValue; HasLocalValue = false; Animation = null; + Expression = null; } public void DeserializeValue(ICoreSerializationContext context) diff --git a/src/Beutl.Engine/Engine/EngineObject.cs b/src/Beutl.Engine/Engine/EngineObject.cs index 2fc119c4e..c6b20d40c 100644 --- a/src/Beutl.Engine/Engine/EngineObject.cs +++ b/src/Beutl.Engine/Engine/EngineObject.cs @@ -1,14 +1,15 @@ using System.ComponentModel; -using System.Linq.Expressions; using System.Reactive.Disposables; using System.Reflection; using Beutl; using Beutl.Animation; +using Beutl.Engine.Expressions; using Beutl.Graphics.Rendering; using Beutl.Media; using Beutl.Reactive; using Beutl.Serialization; using Beutl.Validation; +using LinqExpression = System.Linq.Expressions.Expression; namespace Beutl.Engine; @@ -139,16 +140,30 @@ public override void Deserialize(ICoreSerializationContext context) Dictionary? animations = context.GetValue>("Animations"); + Dictionary? expressions + = context.GetValue>("Expressions"); + foreach (IProperty property in _properties) { property.DeserializeValue(context); - if (property.IsAnimatable && animations?.TryGetValue(property.Name, out IAnimation? value) == true) + if (property.IsAnimatable && animations?.TryGetValue(property.Name, out IAnimation? animation) == true) { - property.Animation = value; + property.Animation = animation; + } + + if (expressions?.TryGetValue(property.Name, out string? expressionString) == true) + { + property.Expression = CreateExpression(property.ValueType, expressionString); } } } + private static IExpression? CreateExpression(Type valueType, string expressionString) + { + var expressionType = typeof(Expression<>).MakeGenericType(valueType); + return Activator.CreateInstance(expressionType, expressionString) as IExpression; + } + public override void Serialize(ICoreSerializationContext context) { base.Serialize(context); @@ -157,6 +172,13 @@ public override void Serialize(ICoreSerializationContext context) .ToDictionary(p => p.Name, p => p.Animation!); context.SetValue("Animations", animations); + + Dictionary expressions = _properties + .Where(p => p.Expression is not null) + .ToDictionary(p => p.Name, p => p.Expression!.ExpressionString); + + context.SetValue("Expressions", expressions); + foreach (IProperty property in _properties) { property.SerializeValue(context); @@ -201,11 +223,11 @@ protected void ScanProperties() where T : EngineObject var func = ReflectionCache.Properties.FirstOrDefault(x => x.Item1 == propertyInfo).Item2; if (func == null) { - var param = Expression.Parameter(typeof(object), "o"); - var cast = Expression.Convert(param, type); - var propertyAccess = Expression.Property(cast, propertyInfo); - var convertResult = Expression.Convert(propertyAccess, typeof(IProperty)); - var lambda = Expression.Lambda>(convertResult, param); + var param = LinqExpression.Parameter(typeof(object), "o"); + var cast = LinqExpression.Convert(param, type); + var propertyAccess = LinqExpression.Property(cast, propertyInfo); + var convertResult = LinqExpression.Convert(propertyAccess, typeof(IProperty)); + var lambda = LinqExpression.Lambda>(convertResult, param); func = lambda.Compile(); ReflectionCache.Properties.Add((propertyInfo, func)); } diff --git a/src/Beutl.Engine/Engine/Expressions/Expression.cs b/src/Beutl.Engine/Engine/Expressions/Expression.cs new file mode 100644 index 000000000..e9c29d263 --- /dev/null +++ b/src/Beutl.Engine/Engine/Expressions/Expression.cs @@ -0,0 +1,161 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; + +namespace Beutl.Engine.Expressions; + +public class Expression : IExpression +{ + private static readonly ScriptOptions s_scriptOptions = CreateScriptOptions(); + + private readonly Lazy _parseResult; + + public Expression(string expression) + { + ExpressionString = expression ?? throw new ArgumentNullException(nameof(expression)); + _parseResult = new Lazy(Parse, LazyThreadSafetyMode.ExecutionAndPublication); + } + + public string ExpressionString { get; } + + public Type ResultType => typeof(T); + + public T Evaluate(ExpressionContext context) + { + var result = _parseResult.Value; + + if (result.ScriptRunner == null) + { + throw new ExpressionException($"Expression parse error: {result.ParseError}"); + } + + try + { + var globals = new ExpressionGlobals(context); + var evalResult = result.ScriptRunner(globals).GetAwaiter().GetResult(); + return ConvertResult(evalResult); + } + catch (Exception ex) when (ex is not ExpressionException) + { + throw new ExpressionException($"Expression evaluation error: {ex.Message}", ex); + } + } + + public bool Validate([NotNullWhen(false)] out string? error) + { + var result = _parseResult.Value; + error = result.ParseError; + return result.ParseError == null; + } + + private ParseResult Parse() + { + try + { + var script = CSharpScript.Create( + ExpressionString, + s_scriptOptions, + typeof(ExpressionGlobals)); + + var diagnostics = script.Compile(); + var errors = diagnostics.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error).ToList(); + + if (errors.Count > 0) + { + return new ParseResult(null, string.Join(Environment.NewLine, errors.Select(e => e.GetMessage()))); + } + else + { + return new ParseResult(script.CreateDelegate(), null); + } + } + catch (Exception ex) + { + return new ParseResult(null, $"Compilation error: {ex.Message}"); + } + } + + private static ScriptOptions CreateScriptOptions() + { + return ScriptOptions.Default + .AddReferences( + typeof(object).Assembly, + typeof(Math).Assembly, + typeof(Console).Assembly, + typeof(Enumerable).Assembly, + typeof(BeutlApplication).Assembly, + typeof(ExpressionGlobals).Assembly) + .AddImports( + "System", + "System.Linq", + "Beutl.Media", + "Beutl.Graphics", + "Beutl.Engine"); + } + + private static T ConvertResult(object? value) + { + if (value == null) + { + return default!; + } + + // Direct assignment if types match + if (value is T typedValue) + { + return typedValue; + } + + var targetType = typeof(T); + var sourceType = value.GetType(); + + // Numeric conversions + if (IsNumericType(targetType) && IsNumericType(sourceType)) + { + return (T)Convert.ChangeType(value, targetType); + } + + // Special handling for bool + if (targetType == typeof(bool) && IsNumericType(sourceType)) + { + double numValue = Convert.ToDouble(value); + return (T)(object)(numValue != 0); + } + + throw new ExpressionException($"Cannot convert expression result from {sourceType.Name} to {targetType.Name}"); + } + + private static bool IsNumericType(Type type) + { + return type == typeof(byte) || type == typeof(sbyte) || + type == typeof(short) || type == typeof(ushort) || + type == typeof(int) || type == typeof(uint) || + type == typeof(long) || type == typeof(ulong) || + type == typeof(float) || type == typeof(double) || + type == typeof(decimal); + } + + public override string ToString() => ExpressionString; + + private sealed record ParseResult(ScriptRunner? ScriptRunner, string? ParseError); +} + +public static class Expression +{ + public static Expression Create(string expressionString) + { + return new Expression(expressionString); + } + + public static bool TryParse(string expressionString, [NotNullWhen(true)] out Expression? expression, [NotNullWhen(false)] out string? error) + { + expression = new Expression(expressionString); + if (expression.Validate(out error)) + { + return true; + } + + expression = null; + return false; + } +} diff --git a/src/Beutl.Engine/Engine/Expressions/ExpressionContext.cs b/src/Beutl.Engine/Engine/Expressions/ExpressionContext.cs new file mode 100644 index 000000000..e251971e6 --- /dev/null +++ b/src/Beutl.Engine/Engine/Expressions/ExpressionContext.cs @@ -0,0 +1,37 @@ +using Beutl.Graphics.Rendering; + +namespace Beutl.Engine.Expressions; + +public class ExpressionContext(TimeSpan time, IProperty currentProperty, PropertyLookup propertyLookup) : RenderContext(time) +{ + private readonly HashSet _evaluationStack = []; + + public IProperty CurrentProperty { get; set; } = currentProperty; + + public PropertyLookup PropertyLookup { get; init; } = propertyLookup; + + public bool TryGetPropertyValue(string path, out T? value) + { + return PropertyLookup.TryGetPropertyValue(path, this, out value); + } + + public bool TryGetPropertyValue(Guid objectId, string propertyName, out T? value) + { + return PropertyLookup.TryGetPropertyValue(objectId, propertyName, this, out value); + } + + public bool IsEvaluating(IProperty property) + { + return _evaluationStack.Contains(property); + } + + public void BeginEvaluation(IProperty property) + { + _evaluationStack.Add(property); + } + + public void EndEvaluation(IProperty property) + { + _evaluationStack.Remove(property); + } +} diff --git a/src/Beutl.Engine/Engine/Expressions/ExpressionException.cs b/src/Beutl.Engine/Engine/Expressions/ExpressionException.cs new file mode 100644 index 000000000..65d6139c3 --- /dev/null +++ b/src/Beutl.Engine/Engine/Expressions/ExpressionException.cs @@ -0,0 +1,14 @@ +namespace Beutl.Engine.Expressions; + +public class ExpressionException : Exception +{ + public ExpressionException(string message) + : base(message) + { + } + + public ExpressionException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Beutl.Engine/Engine/Expressions/ExpressionGlobals.cs b/src/Beutl.Engine/Engine/Expressions/ExpressionGlobals.cs new file mode 100644 index 000000000..cada53bde --- /dev/null +++ b/src/Beutl.Engine/Engine/Expressions/ExpressionGlobals.cs @@ -0,0 +1,112 @@ +using System.Numerics; + +namespace Beutl.Engine.Expressions; + +public class ExpressionGlobals +{ + private readonly ExpressionContext _context; + + public ExpressionGlobals(ExpressionContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + EngineObject? obj = _context.CurrentProperty.GetOwnerObject(); + Start = obj?.TimeRange.Start.TotalSeconds ?? 0; + Duration = obj?.TimeRange.Duration.TotalSeconds ?? 0; + Time = _context.Time.TotalSeconds - Start; + } + + public double Time { get; } + + public double Start { get; } + + public double Duration { get; } + + public double Progress => Duration > 0 ? (double)Time / Duration : 0; + + public double PI => Math.PI; + + public T GetProperty(string path) + { + if (_context.TryGetPropertyValue(path, out var value)) + { + return value!; + } + return default!; + } + public T GetProperty(Guid objectId, string propertyName) + { + string path = $"{objectId}.{propertyName}"; + if (_context.TryGetPropertyValue(path, out var value)) + { + return value!; + } + return default!; + } + + public T Sin(T x) where T : ITrigonometricFunctions => T.Sin(x); + public T Cos(T x) where T : ITrigonometricFunctions => T.Cos(x); + public T Tan(T x) where T : ITrigonometricFunctions => T.Tan(x); + public T Asin(T x) where T : ITrigonometricFunctions => T.Asin(x); + public T Acos(T x) where T : ITrigonometricFunctions => T.Acos(x); + public T Atan(T x) where T : ITrigonometricFunctions => T.Atan(x); + public T Atan2(T y, T x) where T : IFloatingPointIeee754 => T.Atan2(y, x); + public T Sinh(T x) where T : IHyperbolicFunctions => T.Sinh(x); + public T Cosh(T x) where T : IHyperbolicFunctions => T.Cosh(x); + public T Tanh(T x) where T : IHyperbolicFunctions => T.Tanh(x); + public T Sqrt(T x) where T : IRootFunctions => T.Sqrt(x); + public T Pow(T x, T y) where T : IPowerFunctions => T.Pow(x, y); + public T Exp(T x) where T : IExponentialFunctions => T.Exp(x); + public T Log(T x) where T : ILogarithmicFunctions => T.Log(x); + public T Log10(T x) where T : ILogarithmicFunctions => T.Log10(x); + public T Log2(T x) where T : ILogarithmicFunctions => T.Log2(x); + public T Abs(T x) where T : INumberBase => T.Abs(x); + public T Floor(T x) where T : IFloatingPoint => T.Floor(x); + public T Ceil(T x) where T : IFloatingPoint => T.Ceiling(x); + public T Round(T x) where T : IFloatingPoint => T.Round(x); + public T Round(T x, int digits) where T : IFloatingPoint => T.Round(x, digits); + public T Min(T x, T y) where T : INumber => T.Min(x, y); + public T Max(T x, T y) where T : INumber => T.Max(x, y); + public T Clamp(T value, T min, T max) where T : INumber => T.Clamp(value, min, max); + public int Sign(T x) where T : INumber => T.Sign(x); + public T Truncate(T x) where T : IFloatingPointIeee754 => T.Truncate(x); + + public T Lerp(T a, T b, T t) where T : IFloatingPointIeee754 => T.Lerp(a, b, t); + + public T InverseLerp(T a, T b, T value) where T : IFloatingPointIeee754 => (value - a) / (b - a); + + public T Remap(T value, T fromMin, T fromMax, T toMin, T toMax) where T : IFloatingPointIeee754 + { + T t = InverseLerp(fromMin, fromMax, value); + return Lerp(toMin, toMax, t); + } + + public T Smoothstep(T edge0, T edge1, T x) where T : IFloatingPointIeee754, INumber + { + T t = Clamp((x - edge0) / (edge1 - edge0), T.Zero, T.One); + return t * t * (T.CreateChecked(3) - T.CreateChecked(2) * t); + } + + public T Radians(T degrees) where T : IFloatingPointIeee754 => degrees * T.CreateChecked(Math.PI) / T.CreateChecked(180); + + public T Degrees(T radians) where T : IFloatingPointIeee754 => radians * T.CreateChecked(180) / T.CreateChecked(Math.PI); + + public T Mod(T x, T y) where T : IFloatingPointIeee754 => x - y * T.Floor(x / y); + + public T Frac(T x) where T : IFloatingPointIeee754 => x - T.Floor(x); + + public double Random(int seed) + { + var rng = new Random(seed); + return rng.NextDouble(); + } + + public double Random(int seed, double min, double max) + { + var rng = new Random(seed); + return min + rng.NextDouble() * (max - min); + } + + public double FrameRandom() => Random((int)(Time * 1000)); + + public double FrameRandom(double min, double max) => Random((int)(Time * 1000), min, max); +} diff --git a/src/Beutl.Engine/Engine/Expressions/IExpression.cs b/src/Beutl.Engine/Engine/Expressions/IExpression.cs new file mode 100644 index 000000000..9054443e1 --- /dev/null +++ b/src/Beutl.Engine/Engine/Expressions/IExpression.cs @@ -0,0 +1,17 @@ +namespace Beutl.Engine.Expressions; + +public interface IExpression : IExpression +{ + T Evaluate(ExpressionContext context); + + Type IExpression.ResultType => typeof(T); +} + +public interface IExpression +{ + string ExpressionString { get; } + + Type ResultType { get; } + + bool Validate(out string? error); +} diff --git a/src/Beutl.Engine/Engine/Expressions/PropertyLookup.cs b/src/Beutl.Engine/Engine/Expressions/PropertyLookup.cs new file mode 100644 index 000000000..2998e7621 --- /dev/null +++ b/src/Beutl.Engine/Engine/Expressions/PropertyLookup.cs @@ -0,0 +1,59 @@ +namespace Beutl.Engine.Expressions; + +public class PropertyLookup(ICoreObject root) +{ + private readonly ICoreObject _root = root ?? throw new ArgumentNullException(nameof(root)); + + public bool TryGetPropertyValue(string path, ExpressionContext context, out T? value) + { + value = default; + + // Parse the path: "GUID.PropertyName" + int dotIndex = path.IndexOf('.'); + if (dotIndex < 0) + return false; + + string objectIdentifier = path[..dotIndex]; + string propertyName = path[(dotIndex + 1)..]; + + // The identifier must be a GUID + if (!Guid.TryParse(objectIdentifier, out Guid objectId)) + return false; + + return TryGetPropertyValue(objectId, propertyName, context, out value); + } + + public bool TryGetPropertyValue(Guid id, string propertyName, ExpressionContext context, out T? value) + { + value = default; + + if (_root.FindById(id) is not CoreObject coreObject) + return false; + + if (coreObject is EngineObject engineObject) + { + // Find the property + IProperty? property = engineObject.Properties.FirstOrDefault(p => + string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase)); + + if (property is IProperty typedProperty) + { + value = typedProperty.GetValue(context); + return true; + } + } + + + Type type = coreObject.GetType(); + CoreProperty? coreProperty = PropertyRegistry.GetRegistered(type) + .FirstOrDefault(p => string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase)); + + if (coreProperty is CoreProperty typedCoreProperty) + { + value = coreObject.GetValue(typedCoreProperty); + return true; + } + + return false; + } +} diff --git a/src/Beutl.Engine/Engine/IProperty.cs b/src/Beutl.Engine/Engine/IProperty.cs index 97951a78a..1ca5d80a9 100644 --- a/src/Beutl.Engine/Engine/IProperty.cs +++ b/src/Beutl.Engine/Engine/IProperty.cs @@ -1,6 +1,8 @@ using System.Reflection; using Beutl.Animation; using Beutl.Collections; +using Beutl.Engine.Expressions; +using Beutl.Graphics.Rendering; using Beutl.Serialization; using Beutl.Validation; @@ -28,10 +30,14 @@ public interface IProperty : INotifyEdited IAnimation? Animation { get; set; } + IExpression? Expression { get; set; } + bool IsAnimatable { get; } bool HasLocalValue { get; } + bool HasExpression { get; } + void SetPropertyInfo(PropertyInfo propertyInfo); PropertyInfo? GetPropertyInfo(); @@ -57,6 +63,8 @@ public interface IProperty : IProperty new IAnimation? Animation { get; set; } + new IExpression? Expression { get; set; } + object? IProperty.DefaultValue => DefaultValue; IAnimation? IProperty.Animation @@ -65,6 +73,12 @@ public interface IProperty : IProperty set => Animation = (IAnimation?)value; } + IExpression? IProperty.Expression + { + get => Expression; + set => Expression = (IExpression?)value; + } + object? IProperty.CurrentValue { get => CurrentValue; @@ -85,10 +99,12 @@ public interface IProperty : IProperty } } - T GetValue(TimeSpan time); + T GetValue(RenderContext context); event EventHandler>? ValueChanged; + event Action?>? ExpressionChanged; + void operator <<=(T value); } diff --git a/src/Beutl.Engine/Engine/ListProperty.cs b/src/Beutl.Engine/Engine/ListProperty.cs index 875b62136..a30556069 100644 --- a/src/Beutl.Engine/Engine/ListProperty.cs +++ b/src/Beutl.Engine/Engine/ListProperty.cs @@ -1,10 +1,11 @@ -using System.Collections; +using System.Collections; using System.Collections.Specialized; using System.ComponentModel; -using System.ComponentModel.DataAnnotations; using System.Reflection; using Beutl.Animation; using Beutl.Collections; +using Beutl.Engine.Expressions; +using Beutl.Graphics.Rendering; using Beutl.Serialization; using Beutl.Validation; @@ -69,14 +70,28 @@ public IAnimation>? Animation set { } } + public IExpression>? Expression + { + get => null; + set { } + } + public bool HasLocalValue => true; + public bool HasExpression => false; + public event EventHandler>>? ValueChanged { add { } remove { } } + public event Action>?>? ExpressionChanged + { + add { } + remove { } + } + public event EventHandler? Edited; public void operator <<= (ICoreList value) @@ -89,7 +104,7 @@ private void OnChildEdited(object? sender, EventArgs e) Edited?.Invoke(sender, e); } - public ICoreList GetValue(TimeSpan time) + public ICoreList GetValue(RenderContext context) { return CurrentValue; } diff --git a/src/Beutl.Engine/Engine/SimpleProperty.cs b/src/Beutl.Engine/Engine/SimpleProperty.cs index cb2b429ce..454a7fc1d 100644 --- a/src/Beutl.Engine/Engine/SimpleProperty.cs +++ b/src/Beutl.Engine/Engine/SimpleProperty.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.Reflection; using Beutl.Animation; +using Beutl.Engine.Expressions; +using Beutl.Graphics.Rendering; using Beutl.Serialization; using Beutl.Validation; using ValidationContext = Beutl.Validation.ValidationContext; @@ -67,12 +69,29 @@ public IAnimation? Animation } } + public IExpression? Expression + { + get => null; + set + { + if (value != null) + { + throw new InvalidOperationException( + $"Property '{Name}' does not support expressions. Use Property.CreateAnimatable() to create animatable properties."); + } + } + } + public bool HasLocalValue { get; private set; } + public bool HasExpression => false; + public event EventHandler>? ValueChanged; public event EventHandler? Edited; + public event Action?>? ExpressionChanged; + public void operator <<= (T value) { CurrentValue = value; @@ -83,8 +102,9 @@ private void OnChildEdited(object? sender, EventArgs e) Edited?.Invoke(sender, e); } - public T GetValue(TimeSpan time) + public T GetValue(RenderContext context) { + // SimplePropertyは式をサポートしないため、常に現在値を返す return _currentValue; } diff --git a/src/Beutl.Engine/Graphics/Rendering/RenderContext.cs b/src/Beutl.Engine/Graphics/Rendering/RenderContext.cs index 8b36bf2d0..12d34c848 100644 --- a/src/Beutl.Engine/Graphics/Rendering/RenderContext.cs +++ b/src/Beutl.Engine/Graphics/Rendering/RenderContext.cs @@ -12,6 +12,6 @@ public T Get(IProperty property) { if (property == null) throw new ArgumentNullException(nameof(property)); - return property.GetValue(Time); + return property.GetValue(this); } } diff --git a/src/Beutl.Extensibility/IPropertyAdapter.cs b/src/Beutl.Extensibility/IPropertyAdapter.cs index 75494863c..93f6ee653 100644 --- a/src/Beutl.Extensibility/IPropertyAdapter.cs +++ b/src/Beutl.Extensibility/IPropertyAdapter.cs @@ -1,6 +1,7 @@ using System.Reactive.Linq; using Beutl.Animation; using Beutl.Engine; +using Beutl.Engine.Expressions; namespace Beutl.Extensibility; @@ -85,7 +86,7 @@ public interface IAnimatablePropertyAdapter : IPropertyAdapter IObservable ObserveAnimation { get; } } -public interface IAnimatablePropertyAdapter : IPropertyAdapter, IAnimatablePropertyAdapter +public interface IAnimatablePropertyAdapter : IPropertyAdapter, IAnimatablePropertyAdapter, IExpressionPropertyAdapter { new IAnimation? Animation { get; set; } @@ -99,3 +100,28 @@ public interface IAnimatablePropertyAdapter : IPropertyAdapter, IAnimatabl IObservable IAnimatablePropertyAdapter.ObserveAnimation => ObserveAnimation; } + +public interface IExpressionPropertyAdapter : IPropertyAdapter +{ + IExpression? Expression { get; set; } + + bool HasExpression { get; } + + IObservable ObserveExpression { get; } +} + +public interface IExpressionPropertyAdapter : IPropertyAdapter, IExpressionPropertyAdapter +{ + new IExpression? Expression { get; set; } + + new IObservable?> ObserveExpression { get; } + + IExpression? IExpressionPropertyAdapter.Expression + { + get => Expression; + set => Expression = value as IExpression; + } + + IObservable IExpressionPropertyAdapter.ObserveExpression => + ObserveExpression.Select(e => (IExpression?)e); +} diff --git a/src/Beutl.Language/Message.Designer.cs b/src/Beutl.Language/Message.Designer.cs index b9ffae750..6215b9853 100644 --- a/src/Beutl.Language/Message.Designer.cs +++ b/src/Beutl.Language/Message.Designer.cs @@ -590,5 +590,11 @@ public static string Do_you_want_to_install { return ResourceManager.GetString("Do_you_want_to_install", resourceCulture); } } + + public static string ExpressionIsSet { + get { + return ResourceManager.GetString("ExpressionIsSet", resourceCulture); + } + } } } diff --git a/src/Beutl.Language/Message.ja.resx b/src/Beutl.Language/Message.ja.resx index b311948dd..fb8c27759 100644 --- a/src/Beutl.Language/Message.ja.resx +++ b/src/Beutl.Language/Message.ja.resx @@ -398,4 +398,7 @@ インストールしますか? (アップデート機能は実験的な機能です) + + 式が設定されています + diff --git a/src/Beutl.Language/Message.resx b/src/Beutl.Language/Message.resx index c46b52c63..7f13a7feb 100644 --- a/src/Beutl.Language/Message.resx +++ b/src/Beutl.Language/Message.resx @@ -398,4 +398,7 @@ Do you want to restart the application? Do you want to install? (The update function is an experimental feature.) + + Expression is set + diff --git a/src/Beutl.Language/Strings.Designer.cs b/src/Beutl.Language/Strings.Designer.cs index 018238445..dd3d38a1b 100644 --- a/src/Beutl.Language/Strings.Designer.cs +++ b/src/Beutl.Language/Strings.Designer.cs @@ -1382,7 +1382,25 @@ public static string RemoveAnimation { return ResourceManager.GetString("RemoveAnimation", resourceCulture); } } - + + public static string EditExpression { + get { + return ResourceManager.GetString("EditExpression", resourceCulture); + } + } + + public static string RemoveExpression { + get { + return ResourceManager.GetString("RemoveExpression", resourceCulture); + } + } + + public static string ExpressionHelp { + get { + return ResourceManager.GetString("ExpressionHelp", resourceCulture); + } + } + public static string ColorPalette { get { return ResourceManager.GetString("ColorPalette", resourceCulture); @@ -2654,5 +2672,77 @@ public static string PathFollowEffect { return ResourceManager.GetString("PathFollowEffect", resourceCulture); } } + + public static string CopyPropertyPath { + get { + return ResourceManager.GetString("CopyPropertyPath", resourceCulture); + } + } + + public static string CopyGetPropertyCode { + get { + return ResourceManager.GetString("CopyGetPropertyCode", resourceCulture); + } + } + + public static string Expression_Input { + get { + return ResourceManager.GetString("Expression_Input", resourceCulture); + } + } + + public static string Expression_Help { + get { + return ResourceManager.GetString("Expression_Help", resourceCulture); + } + } + + public static string Expression_Variables { + get { + return ResourceManager.GetString("Expression_Variables", resourceCulture); + } + } + + public static string Expression_Functions { + get { + return ResourceManager.GetString("Expression_Functions", resourceCulture); + } + } + + public static string Expression_Variables_Description { + get { + return ResourceManager.GetString("Expression_Variables_Description", resourceCulture); + } + } + + public static string Expression_Functions_Trigonometric { + get { + return ResourceManager.GetString("Expression_Functions_Trigonometric", resourceCulture); + } + } + + public static string Expression_Functions_Math { + get { + return ResourceManager.GetString("Expression_Functions_Math", resourceCulture); + } + } + + public static string Expression_Functions_Interpolation { + get { + return ResourceManager.GetString("Expression_Functions_Interpolation", resourceCulture); + } + } + + public static string Expression_Functions_Utility { + get { + return ResourceManager.GetString("Expression_Functions_Utility", resourceCulture); + } + } + + public static string Expression_GetProperty_Description { + get { + return ResourceManager.GetString("Expression_GetProperty_Description", resourceCulture); + } + } } } diff --git a/src/Beutl.Language/Strings.ja.resx b/src/Beutl.Language/Strings.ja.resx index 6ed4cf489..1335d9673 100644 --- a/src/Beutl.Language/Strings.ja.resx +++ b/src/Beutl.Language/Strings.ja.resx @@ -791,6 +791,21 @@ アニメーションを削除 + + 式を編集 + + + 式を削除 + + + 数式を入力してください。利用可能な変数: Time, Start, Duration, Progress。GetProperty<T>("{GUID}.Property")形式で他のプロパティを参照できます。 + + + プロパティパスをコピー + + + GetPropertyコードをコピー + カラーパレット @@ -1427,4 +1442,44 @@ パス追従 + + 入力 + + + ヘルプ + + + 変数 + + + 関数 + + + Time: 要素の開始からの現在時間(秒) +Start: 要素の開始時間(秒) +Duration: 要素の長さ(秒) +Progress: 正規化された進捗(0.0~1.0) +PI: 数学定数 π(3.14159...) + + + 三角関数: Sin, Cos, Tan, Asin, Acos, Atan, Atan2 +双曲線関数: Sinh, Cosh, Tanh + + + 数学: Sqrt, Pow, Exp, Log, Log10, Log2 +丸め: Abs, Floor, Ceil, Round, Truncate, Sign +範囲: Min, Max, Clamp + + + 補間: Lerp, InverseLerp, Remap, Smoothstep + + + ユーティリティ: Radians, Degrees, Mod, Frac +乱数: Random(seed), Random(seed, min, max), FrameRandom(), FrameRandom(min, max) + + + 他のプロパティを参照: +GetProperty<T>("path") または GetProperty<T>(guid, "propertyName") +例: GetProperty<double>("{GUID}.Opacity") + diff --git a/src/Beutl.Language/Strings.resx b/src/Beutl.Language/Strings.resx index 0e172689d..5b8f0e0ab 100644 --- a/src/Beutl.Language/Strings.resx +++ b/src/Beutl.Language/Strings.resx @@ -786,6 +786,21 @@ Remove animation + + Edit expression + + + Remove expression + + + Enter a mathematical expression. Available variables: Time, Start, Duration, Progress. Use GetProperty<T>("{GUID}.Property") to reference other properties. + + + Copy property path + + + Copy GetProperty code + Color Palette @@ -1422,4 +1437,44 @@ Path Follow + + Input + + + Help + + + Variables + + + Functions + + + Time: Current time relative to the element start (seconds) +Start: Element start time (seconds) +Duration: Element duration (seconds) +Progress: Normalized progress (0.0 to 1.0) +PI: Mathematical constant π (3.14159...) + + + Trigonometric: Sin, Cos, Tan, Asin, Acos, Atan, Atan2 +Hyperbolic: Sinh, Cosh, Tanh + + + Math: Sqrt, Pow, Exp, Log, Log10, Log2 +Rounding: Abs, Floor, Ceil, Round, Truncate, Sign +Range: Min, Max, Clamp + + + Interpolation: Lerp, InverseLerp, Remap, Smoothstep + + + Utility: Radians, Degrees, Mod, Frac +Random: Random(seed), Random(seed, min, max), FrameRandom(), FrameRandom(min, max) + + + Reference other properties: +GetProperty<T>("path") or GetProperty<T>(guid, "propertyName") +Example: GetProperty<double>("{GUID}.Opacity") + diff --git a/src/Beutl.PackageTools.UI/Views/InstallPage.axaml.cs b/src/Beutl.PackageTools.UI/Views/InstallPage.axaml.cs index 08fc7df6a..55ca499f4 100644 --- a/src/Beutl.PackageTools.UI/Views/InstallPage.axaml.cs +++ b/src/Beutl.PackageTools.UI/Views/InstallPage.axaml.cs @@ -9,7 +9,7 @@ using Beutl.PackageTools.UI.Models; using Beutl.PackageTools.UI.ViewModels; - +using Beutl.Reactive; using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls.Primitives; using FluentAvalonia.UI.Navigation; @@ -73,7 +73,7 @@ public InstallPage() // 現在のタスクに応じて、スクロールする this.GetObservable(DataContextProperty) - .Select(v => (v as InstallViewModel)?.CurrentRunningTask ?? Observable.Return((object?)null)) + .Select(v => (v as InstallViewModel)?.CurrentRunningTask ?? Observable.ReturnThenNever(null)) .Switch() .Select(obj => obj switch { diff --git a/src/Beutl.ProjectSystem/NodeTree/NodePropertyAdapter.cs b/src/Beutl.ProjectSystem/NodeTree/NodePropertyAdapter.cs index 4039794bb..c079d56a1 100644 --- a/src/Beutl.ProjectSystem/NodeTree/NodePropertyAdapter.cs +++ b/src/Beutl.ProjectSystem/NodeTree/NodePropertyAdapter.cs @@ -1,5 +1,7 @@ -using System.Text.Json.Nodes; +using System.Reactive.Linq; +using System.Text.Json.Nodes; using Beutl.Animation; +using Beutl.Engine.Expressions; using Beutl.Extensibility; using Beutl.Reactive; using Beutl.Serialization; @@ -82,6 +84,21 @@ public IAnimation? Animation public IObservable?> ObserveAnimation { get; } + // NodeTreeでは式をサポートしない + public IExpression? Expression + { + get => null; + set + { + if (value != null) + throw new NotSupportedException("Expressions are not supported in NodeTree."); + } + } + + public bool HasExpression => false; + + public IObservable?> ObserveExpression { get; } = Observable.ReturnThenNever?>(null); + public Type ImplementedType => typeof(NodePropertyAdapter); public Type PropertyType => typeof(T); diff --git a/src/Beutl.ProjectSystem/Operation/AnimatablePropertyAdapter.cs b/src/Beutl.ProjectSystem/Operation/AnimatablePropertyAdapter.cs index 16e2adafa..391481d58 100644 --- a/src/Beutl.ProjectSystem/Operation/AnimatablePropertyAdapter.cs +++ b/src/Beutl.ProjectSystem/Operation/AnimatablePropertyAdapter.cs @@ -2,6 +2,7 @@ using System.Reactive.Linq; using Beutl.Animation; using Beutl.Engine; +using Beutl.Engine.Expressions; using Beutl.Extensibility; namespace Beutl.Operation; @@ -21,4 +22,19 @@ public IAnimation? Animation handler => ((AnimatableProperty)Property).AnimationChanged -= handler) .Publish(Animation) .RefCount(); + + public IExpression? Expression + { + get => ((AnimatableProperty)Property).Expression; + set => ((AnimatableProperty)Property).Expression = value; + } + + public bool HasExpression => ((AnimatableProperty)Property).HasExpression; + + [field: AllowNull] + public IObservable?> ObserveExpression => field ??= Observable.FromEvent?>( + handler => ((AnimatableProperty)Property).ExpressionChanged += handler, + handler => ((AnimatableProperty)Property).ExpressionChanged -= handler) + .Publish(Expression) + .RefCount(); } diff --git a/src/Beutl/App.axaml.cs b/src/Beutl/App.axaml.cs index 26e91b895..90c6bef64 100644 --- a/src/Beutl/App.axaml.cs +++ b/src/Beutl/App.axaml.cs @@ -70,6 +70,11 @@ public override void Initialize() }, DispatcherPriority.Send); }); + if (!OperatingSystem.IsWindows()) + { + _theme.RemoveRange(1, _theme.Count - 1); + } + if (view.UseCustomAccentColor && Color.TryParse(view.CustomAccentColor, out Color customColor)) { activity?.SetTag("CustomAccentColor", customColor.ToString()); diff --git a/src/Beutl/Helpers/AvaloniaTypeConverter.cs b/src/Beutl/Helpers/AvaloniaTypeConverter.cs index db9b7e4e1..5c774a4f8 100644 --- a/src/Beutl/Helpers/AvaloniaTypeConverter.cs +++ b/src/Beutl/Helpers/AvaloniaTypeConverter.cs @@ -61,7 +61,7 @@ public static IObservable SubscribeEngineProperty( .Select(_ => Unit.Default) .Publish(Unit.Default).RefCount() .CombineLatest(time) - .Select(t => property.GetValue(t.Second)); + .Select(t => property.GetValue(new RenderContext(t.Second))); } public static IObservable SubscribeEngineResource( diff --git a/src/Beutl/Pages/SettingsPages/AccountSettingsPage.axaml.cs b/src/Beutl/Pages/SettingsPages/AccountSettingsPage.axaml.cs index f4cbbf019..e5c466ffd 100644 --- a/src/Beutl/Pages/SettingsPages/AccountSettingsPage.axaml.cs +++ b/src/Beutl/Pages/SettingsPages/AccountSettingsPage.axaml.cs @@ -15,7 +15,7 @@ public AccountSettingsPage() .Select(v => v as AccountSettingsPageViewModel); IObservable signedIn = viewModel - .Select(v => v?.SignedIn.Select(v => (bool?)v) ?? Observable.Return(null)) + .Select(v => v?.SignedIn.Select(v => (bool?)v) ?? Observable.ReturnThenNever(null)) .Switch(); signedIn diff --git a/src/Beutl/ViewModels/EditViewModel.cs b/src/Beutl/ViewModels/EditViewModel.cs index c0ffe087d..4eaf57a52 100644 --- a/src/Beutl/ViewModels/EditViewModel.cs +++ b/src/Beutl/ViewModels/EditViewModel.cs @@ -82,7 +82,7 @@ public EditViewModel(Scene scene) SelectedLayerNumber = SelectedObject.Select(v => (v as Element)?.GetObservable(Element.ZIndexProperty).Select(i => (int?)i) ?? - Observable.Return(null)) + Observable.ReturnThenNever(null)) .Switch() .ToReadOnlyReactivePropertySlim(); diff --git a/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs index 69c2c9e8a..2b565e35c 100644 --- a/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/AudioEffectEditorViewModel.cs @@ -67,7 +67,7 @@ public AudioEffectEditorViewModel(IPropertyAdapter property) .DisposeWith(Disposables)) .DisposeWith(Disposables); - IsEnabled = Value.Select(x => x?.GetObservable(EngineObject.IsEnabledProperty) ?? Observable.Return(x?.IsEnabled ?? false)) + IsEnabled = Value.Select(x => x?.GetObservable(EngineObject.IsEnabledProperty) ?? Observable.ReturnThenNever(x?.IsEnabled ?? false)) .Switch() .ToReactiveProperty() .DisposeWith(Disposables); diff --git a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs index 94e8c7609..84263def2 100644 --- a/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/BaseEditorViewModel.cs @@ -8,7 +8,7 @@ using Beutl.Animation; using Beutl.Animation.Easings; using Beutl.Controls.PropertyEditors; -using Beutl.Engine; +using Beutl.Engine.Expressions; using Beutl.Media; using Beutl.Helpers; using Beutl.Logging; @@ -44,16 +44,22 @@ protected BaseEditorViewModel(IPropertyAdapter property) IObservable hasAnimation = property is IAnimatablePropertyAdapter anm ? anm.ObserveAnimation.Select(x => x != null) - : Observable.Return(false); + : Observable.ReturnThenNever(false); - IObservable? isReadOnly = Observable.Return(property.IsReadOnly); + IObservable hasExpression = property is IExpressionPropertyAdapter expr + ? expr.ObserveExpression.Select(x => x != null) + : Observable.ReturnThenNever(false); + IObservable? isReadOnly = Observable.ReturnThenNever(property.IsReadOnly); + + // TODO: CanEditとIsReadOnlyどちらかだけにしたい CanEdit = isReadOnly .Not() + .CombineLatest(hasExpression, (canEdit, hasExpr) => canEdit && !hasExpr) .ToReadOnlyReactivePropertySlim() .AddTo(Disposables); - IsReadOnly = isReadOnly + IsReadOnly = CanEdit.Not() .ToReadOnlyReactivePropertySlim() .AddTo(Disposables); @@ -61,10 +67,14 @@ protected BaseEditorViewModel(IPropertyAdapter property) .ToReadOnlyReactiveProperty() .AddTo(Disposables); + HasExpression = hasExpression + .ToReadOnlyReactiveProperty() + .AddTo(Disposables); + if (property is IAnimatablePropertyAdapter animatableProperty) { KeyFrameCount = animatableProperty.ObserveAnimation - .Select(x => (x as IKeyFrameAnimation)?.KeyFrames.ObserveProperty(y => y.Count) ?? Observable.Return(0)) + .Select(x => (x as IKeyFrameAnimation)?.KeyFrames.ObserveProperty(y => y.Count) ?? Observable.ReturnThenNever(0)) .Switch() .ToReadOnlyReactiveProperty() .DisposeWith(Disposables); @@ -77,7 +87,7 @@ protected BaseEditorViewModel(IPropertyAdapter property) .Select(_ => x) .Publish(x) .RefCount()) - .Select(x => x ?? Observable.Return(default)) + .Select(x => x ?? Observable.ReturnThenNever(default)) .Switch()) .CombineWithPrevious() .Subscribe(t => @@ -116,7 +126,7 @@ protected BaseEditorViewModel(IPropertyAdapter property) } else { - KeyFrameCount = new ReadOnlyReactiveProperty(Observable.Return(0)); + KeyFrameCount = new ReadOnlyReactiveProperty(Observable.ReturnThenNever(0)); } } @@ -148,6 +158,8 @@ protected BaseEditorViewModel(IPropertyAdapter property) public ReadOnlyReactiveProperty HasAnimation { get; } + public ReadOnlyReactiveProperty HasExpression { get; } + public ReactivePropertySlim IsSymbolIconFilled { get; } = new(); public ReactivePropertySlim EditingKeyFrame { get; } = new(); @@ -213,7 +225,7 @@ public virtual void Accept(IPropertyEditorContextVisitor visitor) .Select(_ => x) .Publish(x) .RefCount()) - .Select(x => x ?? Observable.Return(default)) + .Select(x => x ?? Observable.ReturnThenNever(default)) .Switch()) .Subscribe(t => { @@ -293,6 +305,21 @@ public virtual void RemoveAnimation() { } + public virtual bool SetExpression(string expressionString, [NotNullWhen(false)] out string? error) + { + error = null; + return true; + } + + public virtual void RemoveExpression() + { + } + + public virtual string? GetExpressionString() + { + return null; + } + public virtual object? GetService(Type serviceType) { if (serviceType.IsAssignableTo(typeof(IPropertyAdapter))) @@ -438,9 +465,30 @@ public override void PrepareToEditAnimation() Value = initialValue, Easing = new SplineEasing(), KeyTime = TimeSpan.Zero }); + // Expressionを保存してnullに設定する + IExpression? oldExpression = null; + if (PropertyAdapter is IExpressionPropertyAdapter expressionProperty) + { + oldExpression = expressionProperty.Expression; + } + RecordableCommands.Create(GetStorables()) - .OnDo(() => animatableProperty.Animation = newAnimation) - .OnUndo(() => animatableProperty.Animation = oldAnimation) + .OnDo(() => + { + animatableProperty.Animation = newAnimation; + if (PropertyAdapter is IExpressionPropertyAdapter ep) + { + ep.Expression = null; + } + }) + .OnUndo(() => + { + animatableProperty.Animation = oldAnimation; + if (PropertyAdapter is IExpressionPropertyAdapter ep) + { + ep.Expression = oldExpression; + } + }) .ToCommand() .DoAndRecord(recorder); } @@ -460,4 +508,72 @@ public override void RemoveAnimation() .DoAndRecord(recorder); } } + + public override bool SetExpression(string expressionString, [NotNullWhen(false)] out string? error) + { + if (PropertyAdapter is IExpressionPropertyAdapter expressionProperty) + { + CommandRecorder recorder = this.GetRequiredService(); + IExpression? oldExpression = expressionProperty.Expression; + + if (!Expression.TryParse(expressionString, out var newExpression, out error)) + { + return false; + } + + // Animationを保存してnullに設定する + IAnimation? oldAnimation = null; + if (PropertyAdapter is IAnimatablePropertyAdapter animatableProperty) + { + oldAnimation = animatableProperty.Animation; + } + + RecordableCommands.Create(GetStorables()) + .OnDo(() => + { + expressionProperty.Expression = newExpression; + if (PropertyAdapter is IAnimatablePropertyAdapter ap) + { + ap.Animation = null; + } + }) + .OnUndo(() => + { + expressionProperty.Expression = oldExpression; + if (PropertyAdapter is IAnimatablePropertyAdapter ap) + { + ap.Animation = oldAnimation; + } + }) + .ToCommand() + .DoAndRecord(recorder); + } + + error = null; + return true; + } + + public override void RemoveExpression() + { + if (PropertyAdapter is IExpressionPropertyAdapter expressionProperty) + { + CommandRecorder recorder = this.GetRequiredService(); + IExpression? oldExpression = expressionProperty.Expression; + + RecordableCommands.Create(GetStorables()) + .OnDo(() => expressionProperty.Expression = null) + .OnUndo(() => expressionProperty.Expression = oldExpression) + .ToCommand() + .DoAndRecord(recorder); + } + } + + public override string? GetExpressionString() + { + if (PropertyAdapter is IExpressionPropertyAdapter expressionProperty) + { + return expressionProperty.Expression?.ExpressionString; + } + return null; + } } diff --git a/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs b/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs index 2be825298..2a90015fa 100644 --- a/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/FilterEffectEditorViewModel.cs @@ -71,7 +71,7 @@ public FilterEffectEditorViewModel(IPropertyAdapter property) .DisposeWith(Disposables)) .DisposeWith(Disposables); - IsEnabled = Value.Select(x => x?.GetObservable(FilterEffect.IsEnabledProperty) ?? Observable.Return(x?.IsEnabled ?? false)) + IsEnabled = Value.Select(x => x?.GetObservable(FilterEffect.IsEnabledProperty) ?? Observable.ReturnThenNever(x?.IsEnabled ?? false)) .Switch() .ToReactiveProperty() .DisposeWith(Disposables); diff --git a/src/Beutl/ViewModels/Editors/PathFigureEditorViewModel.cs b/src/Beutl/ViewModels/Editors/PathFigureEditorViewModel.cs index 801780afc..8fe25fe9f 100644 --- a/src/Beutl/ViewModels/Editors/PathFigureEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/PathFigureEditorViewModel.cs @@ -47,7 +47,7 @@ public PathFigureEditorViewModel(IPropertyAdapter property) .DisposeWith(Disposables); EditingPath = _editViewModel - .Select(v => v?.Player.PathEditor.PathFigure ?? Observable.Return(null)) + .Select(v => v?.Player.PathEditor.PathFigure ?? Observable.ReturnThenNever(null)) .Switch() .CombineLatest(Value) .Select(t => t.First == t.Second && t.First != null) diff --git a/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs b/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs index 4e96072f1..52730a813 100644 --- a/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/TransformEditorViewModel.cs @@ -111,7 +111,7 @@ public TransformEditorViewModel(IPropertyAdapter property) .DisposeWith(Disposables)) .DisposeWith(Disposables); - IsEnabled = Value.Select(x => (x as Transform)?.GetObservable(Transform.IsEnabledProperty) ?? Observable.Return(x?.IsEnabled ?? false)) + IsEnabled = Value.Select(x => (x as Transform)?.GetObservable(Transform.IsEnabledProperty) ?? Observable.ReturnThenNever(x?.IsEnabled ?? false)) .Switch() .ToReactiveProperty() .DisposeWith(Disposables); diff --git a/src/Beutl/ViewModels/Editors/ValueEditorViewModel.cs b/src/Beutl/ViewModels/Editors/ValueEditorViewModel.cs index f4697178c..8e8ad552e 100644 --- a/src/Beutl/ViewModels/Editors/ValueEditorViewModel.cs +++ b/src/Beutl/ViewModels/Editors/ValueEditorViewModel.cs @@ -1,5 +1,6 @@ using Beutl.Animation; - +using Beutl.Graphics.Rendering; +using Beutl.Operation; using Reactive.Bindings; using Reactive.Bindings.Extensions; @@ -10,10 +11,17 @@ public class ValueEditorViewModel : BaseEditorViewModel public ValueEditorViewModel(IPropertyAdapter property) : base(property) { - Value = EditingKeyFrame - .Select(x => x?.GetObservable(KeyFrame.ValueProperty)) - .Select(x => x ?? PropertyAdapter.GetObservable()) - //https://qiita.com/hiki_neet_p/items/4a8873920b566568d63b + // Expressionが設定されている場合は、PropertyAdapterの値を直接表示(実際の評価値) + // Expressionが設定されていない場合は、EditingKeyFrameまたはPropertyAdapterの値を表示 + Value = HasExpression + .Select(hasExpression => + hasExpression && PropertyAdapter is EnginePropertyAdapter { Property: var engineProperty } + ? CurrentTime.Select(t => engineProperty.GetValue(new RenderContext(t))) + // Expressionが設定されていない場合は通常の動作 + : EditingKeyFrame + .Select(x => x?.GetObservable(KeyFrame.ValueProperty)) + .Select(x => x ?? PropertyAdapter.GetObservable()) + .Switch()) .Switch() .ToReadOnlyReactiveProperty() .AddTo(Disposables)!; diff --git a/src/Beutl/ViewModels/ElementScopeViewModel.cs b/src/Beutl/ViewModels/ElementScopeViewModel.cs index 41e4c788b..72fe8e3e2 100644 --- a/src/Beutl/ViewModels/ElementScopeViewModel.cs +++ b/src/Beutl/ViewModels/ElementScopeViewModel.cs @@ -49,7 +49,7 @@ public ElementScopeViewModel(Element element, ElementViewModel parent) // CountがZero if (t.EndZIndex - 1 == t.ZIndex) { - return Observable.Return(0d); + return Observable.ReturnThenNever(0d); } else { diff --git a/src/Beutl/ViewModels/GraphEditorKeyFrameViewModel.cs b/src/Beutl/ViewModels/GraphEditorKeyFrameViewModel.cs index cd72bc060..e7ba34f0b 100644 --- a/src/Beutl/ViewModels/GraphEditorKeyFrameViewModel.cs +++ b/src/Beutl/ViewModels/GraphEditorKeyFrameViewModel.cs @@ -108,7 +108,7 @@ public GraphEditorKeyFrameViewModel( } else { - return Observable.Return<(Vector, Vector)>(default); + return Observable.ReturnThenNever<(Vector, Vector)>(default); } }) .Switch(); diff --git a/src/Beutl/ViewModels/GraphEditorViewModel.cs b/src/Beutl/ViewModels/GraphEditorViewModel.cs index 2b2288d58..e3f2b452b 100644 --- a/src/Beutl/ViewModels/GraphEditorViewModel.cs +++ b/src/Beutl/ViewModels/GraphEditorViewModel.cs @@ -85,20 +85,20 @@ protected GraphEditorViewModel(EditViewModel editViewModel, IKeyFrameAnimation a .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); - ElementMargin = (Element?.GetObservable(Element.StartProperty) ?? Observable.Return(default)) + ElementMargin = (Element?.GetObservable(Element.StartProperty) ?? Observable.ReturnThenNever(default)) .CombineLatest(editViewModel.Scale) .Select(t => new Thickness(t.First.ToPixel(t.Second), 0, 0, 0)) .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); - ElementWidth = (Element?.GetObservable(Element.LengthProperty) ?? Observable.Return(default)) + ElementWidth = (Element?.GetObservable(Element.LengthProperty) ?? Observable.ReturnThenNever(default)) .CombineLatest(editViewModel.Scale) .Select(t => t.First.ToPixel(t.Second)) .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); ElementColor = (Element?.GetObservable(Element.AccentColorProperty) ?? - Observable.Return(Beutl.Media.Colors.Transparent)) + Observable.ReturnThenNever(Beutl.Media.Colors.Transparent)) .Select(v => (IBrush)new ImmutableSolidColorBrush(v.ToAvalonia())) .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); @@ -108,7 +108,7 @@ protected GraphEditorViewModel(EditViewModel editViewModel, IKeyFrameAnimation a .CombineLatest(Options) .Select(item => new Thickness(item.First.ToPixel(item.Second.Scale), 0, 0, 0)) : null) - .Select(v => v ?? Observable.Return(default)) + .Select(v => v ?? Observable.ReturnThenNever(default)) .Switch() .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); diff --git a/src/Beutl/ViewModels/InlineAnimationLayerViewModel.cs b/src/Beutl/ViewModels/InlineAnimationLayerViewModel.cs index 78b6fbfde..1714009da 100644 --- a/src/Beutl/ViewModels/InlineAnimationLayerViewModel.cs +++ b/src/Beutl/ViewModels/InlineAnimationLayerViewModel.cs @@ -84,7 +84,7 @@ protected InlineAnimationLayerViewModel( Element.LayerHeader.Subscribe(OnLayerHeaderChanged).DisposeWith(_disposables); - LeftMargin = _useGlobalClock.Select(v => !v ? element.BorderMargin : Observable.Return(default)) + LeftMargin = _useGlobalClock.Select(v => !v ? element.BorderMargin : Observable.ReturnThenNever(default)) .Switch() .ToReactiveProperty() .DisposeWith(_disposables); diff --git a/src/Beutl/ViewModels/NodeTree/InputSocketViewModel.cs b/src/Beutl/ViewModels/NodeTree/InputSocketViewModel.cs index dab73f084..6f0babcb9 100644 --- a/src/Beutl/ViewModels/NodeTree/InputSocketViewModel.cs +++ b/src/Beutl/ViewModels/NodeTree/InputSocketViewModel.cs @@ -12,7 +12,7 @@ public InputSocketViewModel(IInputSocket? socket, IPropertyEditorContext? proper : base(socket, propertyEditorContext, node, editViewModel) { Status = _statusSource - .Select(o => o ?? Observable.Return(ConnectionStatus.Disconnected)) + .Select(o => o ?? Observable.ReturnThenNever(ConnectionStatus.Disconnected)) .Switch() .ToReadOnlyReactivePropertySlim(); } diff --git a/src/Beutl/ViewModels/NodeTree/NodeItemViewModel.cs b/src/Beutl/ViewModels/NodeTree/NodeItemViewModel.cs index 8a07102ec..853c50394 100644 --- a/src/Beutl/ViewModels/NodeTree/NodeItemViewModel.cs +++ b/src/Beutl/ViewModels/NodeTree/NodeItemViewModel.cs @@ -20,7 +20,7 @@ public NodeItemViewModel(INodeItem? nodeItem, IPropertyEditorContext? propertyEd } else { - Name = Observable.Return(string.Empty).ToReadOnlyReactiveProperty()!; + Name = Observable.ReturnThenNever(string.Empty).ToReadOnlyReactiveProperty()!; } Model = nodeItem; diff --git a/src/Beutl/ViewModels/PathEditorViewModel.cs b/src/Beutl/ViewModels/PathEditorViewModel.cs index aeb143169..5a84e1126 100644 --- a/src/Beutl/ViewModels/PathEditorViewModel.cs +++ b/src/Beutl/ViewModels/PathEditorViewModel.cs @@ -22,17 +22,17 @@ public PathEditorViewModel(EditViewModel editViewModel, PlayerViewModel playerVi .Select(v => v.Width) .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); - Context = FigureContext.Select(v => v?.ParentContext ?? Observable.Return(null)) + Context = FigureContext.Select(v => v?.ParentContext ?? Observable.ReturnThenNever(null)) .Switch() .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); - PathGeometry = Context.Select(v => v?.Value ?? Observable.Return(null)) + PathGeometry = Context.Select(v => v?.Value ?? Observable.ReturnThenNever(null)) .Switch() .Select(v => v as PathGeometry) .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); - PathFigure = FigureContext.Select(v => v?.Value ?? Observable.Return(null)) + PathFigure = FigureContext.Select(v => v?.Value ?? Observable.ReturnThenNever(null)) .Switch() .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); @@ -50,7 +50,7 @@ public PathEditorViewModel(EditViewModel editViewModel, PlayerViewModel playerVi .Select(d => d?.SubscribeEngineVersionedResource(EditViewModel.CurrentTime, (o, c) => o.ToResource(c)) .Select(t => ((Drawable.Resource, int)?)t) ?? - Observable.Return<(Drawable.Resource, int)?>(null)) + Observable.ReturnThenNever<(Drawable.Resource, int)?>(null)) .Switch() .Publish(null).RefCount(); @@ -73,7 +73,7 @@ public PathEditorViewModel(EditViewModel editViewModel, PlayerViewModel playerVi .CombineLatest(Element .Select(e => e?.GetObservable(ProjectSystem.Element.StartProperty) .CombineLatest(e.GetObservable(ProjectSystem.Element.LengthProperty)) - .Select(t => new TimeRange(t.First, t.Second)) ?? Observable.Return(default)) + .Select(t => new TimeRange(t.First, t.Second)) ?? Observable.ReturnThenNever(default)) .Switch()) .Select(t => t.Second.Contains(t.First)) .CombineLatest(PlayerViewModel.IsPlaying, Context) @@ -82,7 +82,7 @@ public PathEditorViewModel(EditViewModel editViewModel, PlayerViewModel playerVi .DisposeWith(_disposables); IsClosed = PathFigure.Select(g => - g?.IsClosed.SubscribeEngineProperty(g, EditViewModel.CurrentTime) ?? Observable.Return(false)) + g?.IsClosed.SubscribeEngineProperty(g, EditViewModel.CurrentTime) ?? Observable.ReturnThenNever(false)) .Switch() .ToReadOnlyReactiveProperty() .DisposeWith(_disposables); diff --git a/src/Beutl/ViewModels/SettingsPages/AccountSettingsPageViewModel.cs b/src/Beutl/ViewModels/SettingsPages/AccountSettingsPageViewModel.cs index 6c4e2c34d..42b7fb6c1 100644 --- a/src/Beutl/ViewModels/SettingsPages/AccountSettingsPageViewModel.cs +++ b/src/Beutl/ViewModels/SettingsPages/AccountSettingsPageViewModel.cs @@ -44,7 +44,7 @@ public AccountSettingsPageViewModel(BeutlApiApplication clients) .DisposeWith(_disposables); ProfileImage = _clients.AuthorizedUser - .SelectMany(x => x?.Profile?.AvatarUrl ?? Observable.Return(null)) + .SelectMany(x => x?.Profile?.AvatarUrl ?? Observable.ReturnThenNever(null)) .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); @@ -54,7 +54,7 @@ public AccountSettingsPageViewModel(BeutlApiApplication clients) .DisposeWith(_disposables); DisplayName = _clients.AuthorizedUser - .SelectMany(x => x?.Profile?.DisplayName ?? Observable.Return(null)) + .SelectMany(x => x?.Profile?.DisplayName ?? Observable.ReturnThenNever(null)) .Zip(Name, (x, y) => string.IsNullOrEmpty(x) ? y : x) .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); diff --git a/src/Beutl/ViewModels/Tools/OutputTabViewModel.cs b/src/Beutl/ViewModels/Tools/OutputTabViewModel.cs index 7f24d1d9f..28775f884 100644 --- a/src/Beutl/ViewModels/Tools/OutputTabViewModel.cs +++ b/src/Beutl/ViewModels/Tools/OutputTabViewModel.cs @@ -18,7 +18,7 @@ public OutputTabViewModel(EditViewModel editViewModel) EditViewModel = editViewModel; _outputService = new OutputService(editViewModel); CanRemove = SelectedItem - .Select(x => x?.Context?.IsEncoding?.Not() ?? Observable.Return(false)) + .Select(x => x?.Context?.IsEncoding?.Not() ?? Observable.ReturnThenNever(false)) .Switch() .ToReadOnlyReactivePropertySlim(); ReadFromJson(null); diff --git a/src/Beutl/ViewModels/Tools/PathEditorTabViewModel.cs b/src/Beutl/ViewModels/Tools/PathEditorTabViewModel.cs index e1494c01a..6c1531be4 100644 --- a/src/Beutl/ViewModels/Tools/PathEditorTabViewModel.cs +++ b/src/Beutl/ViewModels/Tools/PathEditorTabViewModel.cs @@ -16,17 +16,17 @@ public sealed class PathEditorTabViewModel : IDisposable, IPathEditorViewModel, public PathEditorTabViewModel(EditViewModel editViewModel) { EditViewModel = editViewModel; - Context = FigureContext.Select(v => v?.ParentContext ?? Observable.Return(null)) + Context = FigureContext.Select(v => v?.ParentContext ?? Observable.ReturnThenNever(null)) .Switch() .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); - PathGeometry = Context.Select(v => v?.Value ?? Observable.Return(null)) + PathGeometry = Context.Select(v => v?.Value ?? Observable.ReturnThenNever(null)) .Switch() .Select(v => v as PathGeometry) .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); - PathFigure = FigureContext.Select(v => v?.Value ?? Observable.Return(null)) + PathFigure = FigureContext.Select(v => v?.Value ?? Observable.ReturnThenNever(null)) .Switch() .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); @@ -41,14 +41,14 @@ public PathEditorTabViewModel(EditViewModel editViewModel) .Select(d => d?.SubscribeEngineVersionedResource(EditViewModel.CurrentTime, (o, c) => o.ToResource(c)) .Select(t => ((PathGeometry.Resource, int)?)t) ?? - Observable.Return<(PathGeometry.Resource, int)?>(null)) + Observable.ReturnThenNever<(PathGeometry.Resource, int)?>(null)) .Switch() .ToReadOnlyReactivePropertySlim() .DisposeWith(_disposables); IsClosed = PathFigure.Select(f => f != null ? f.IsClosed.SubscribeEngineProperty(f, EditViewModel.CurrentTime) - : Observable.Return(false)) + : Observable.ReturnThenNever(false)) .Switch() .ToReadOnlyReactiveProperty() .DisposeWith(_disposables); diff --git a/src/Beutl/ViewModels/Tools/SourceOperatorViewModel.cs b/src/Beutl/ViewModels/Tools/SourceOperatorViewModel.cs index 0f9d21350..073743d84 100644 --- a/src/Beutl/ViewModels/Tools/SourceOperatorViewModel.cs +++ b/src/Beutl/ViewModels/Tools/SourceOperatorViewModel.cs @@ -37,10 +37,10 @@ public SourceOperatorViewModel(SourceOperator model, SourceOperatorsTabViewModel model.Properties.CollectionChanged += Properties_CollectionChanged; - IsDummy = Observable.Return(model is IDummy) + IsDummy = Observable.ReturnThenNever(model is IDummy) .ToReadOnlyReactivePropertySlim(); - ActualTypeName = Observable.Return(DummyHelper.GetTypeName(model)) + ActualTypeName = Observable.ReturnThenNever(DummyHelper.GetTypeName(model)) .ToReadOnlyReactivePropertySlim()!; } @@ -197,10 +197,10 @@ public void Visit(IPropertyEditorContext context) { if (Model is DummySourceOperator { Json: JsonObject json }) { - return Observable.Return(json.ToJsonString(JsonHelper.SerializerOptions)); + return Observable.ReturnThenNever(json.ToJsonString(JsonHelper.SerializerOptions)); } - return Observable.Return((string?)null); + return Observable.ReturnThenNever(null); } public void SetJsonString(string? str) diff --git a/src/Beutl/Views/Editors/ExpressionEditorFlyout.cs b/src/Beutl/Views/Editors/ExpressionEditorFlyout.cs new file mode 100644 index 000000000..4b04fd202 --- /dev/null +++ b/src/Beutl/Views/Editors/ExpressionEditorFlyout.cs @@ -0,0 +1,136 @@ +using System.ComponentModel; +using Avalonia.Controls; +using Avalonia.Input; +using Beutl.Controls.PropertyEditors; +using FluentAvalonia.Core; +using FluentAvalonia.UI.Controls.Primitives; + +namespace Beutl.Views.Editors; + +public sealed class ExpressionEditorFlyout : PickerFlyoutBase +{ + private ExpressionEditorFlyoutPresenter? _presenter; + + public string? ExpressionText + { + get => _presenter?.ExpressionText?? field; + set + { + field = value; + if (_presenter != null) + { + _presenter.ExpressionText = value; + } + } + } + + public string? ErrorMessage + { + get => _presenter?.ErrorMessage ?? field; + set + { + field = value; + if (_presenter != null) + { + _presenter.ErrorMessage = value; + } + } + } + + public event TypedEventHandler? Confirmed; + + public event TypedEventHandler? Dismissed; + + protected override Control CreatePresenter() + { + _presenter = new ExpressionEditorFlyoutPresenter(); + _presenter.ExpressionText = ExpressionText; + _presenter.ErrorMessage = ErrorMessage; + _presenter.Confirmed += OnFlyoutConfirmed; + _presenter.Dismissed += OnFlyoutDismissed; + _presenter.CloseClicked += OnFlyoutCloseClicked; + _presenter.KeyDown += OnFlyoutKeyDown; + + return _presenter; + } + + private void OnFlyoutKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter && e.KeyModifiers == KeyModifiers.Control) + { + OnConfirmed(); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + Dismissed?.Invoke(this, EventArgs.Empty); + Hide(); + e.Handled = true; + } + } + + protected override void OnConfirmed() + { + string expressionText = ExpressionText ?? ""; + + if (!string.IsNullOrWhiteSpace(expressionText)) + { + var args = new ExpressionConfirmedEventArgs(expressionText); + Confirmed?.Invoke(this, args); + + if (!args.IsValid) + { + ErrorMessage = args.Error; + return; + } + } + + ErrorMessage = null; + Hide(); + } + + protected override void OnOpening(CancelEventArgs args) + { + base.OnOpening(args); + + if (Popup.Child is ExpressionEditorFlyoutPresenter pfp) + { + pfp.ShowHideButtons = ShouldShowConfirmationButtons(); + } + + Popup.IsLightDismissEnabled = false; + } + + protected override bool ShouldShowConfirmationButtons() => true; + + private void OnFlyoutCloseClicked(DraggablePickerFlyoutPresenter sender, EventArgs args) + { + Dismissed?.Invoke(this, EventArgs.Empty); + Hide(); + } + + private void OnFlyoutDismissed(DraggablePickerFlyoutPresenter sender, EventArgs args) + { + Dismissed?.Invoke(this, EventArgs.Empty); + Hide(); + } + + private void OnFlyoutConfirmed(DraggablePickerFlyoutPresenter sender, EventArgs args) + { + OnConfirmed(); + } +} + +public sealed class ExpressionConfirmedEventArgs : EventArgs +{ + public ExpressionConfirmedEventArgs(string expressionText) + { + ExpressionText = expressionText; + } + + public string ExpressionText { get; } + + public bool IsValid { get; set; } = true; + + public string? Error { get; set; } +} diff --git a/src/Beutl/Views/Editors/FilterEffectEditor.axaml.cs b/src/Beutl/Views/Editors/FilterEffectEditor.axaml.cs index d792c2980..5a79c1ea8 100644 --- a/src/Beutl/Views/Editors/FilterEffectEditor.axaml.cs +++ b/src/Beutl/Views/Editors/FilterEffectEditor.axaml.cs @@ -45,7 +45,7 @@ public FilterEffectEditor() this.GetObservable(DataContextProperty) .Select(x => x as FilterEffectEditorViewModel) - .Select(x => x?.IsDummy.Select(_ => x) ?? Observable.Return(null)) + .Select(x => x?.IsDummy.Select(_ => x) ?? Observable.ReturnThenNever(null)) .Switch() .Where(v => v?.IsDummy.Value == true) .Take(1) diff --git a/src/Beutl/Views/Editors/FilterEffectListItemEditor.axaml.cs b/src/Beutl/Views/Editors/FilterEffectListItemEditor.axaml.cs index 62d434eaf..26663de61 100644 --- a/src/Beutl/Views/Editors/FilterEffectListItemEditor.axaml.cs +++ b/src/Beutl/Views/Editors/FilterEffectListItemEditor.axaml.cs @@ -36,7 +36,7 @@ public FilterEffectListItemEditor() this.GetObservable(DataContextProperty) .Select(x => x as FilterEffectEditorViewModel) - .Select(x => x?.IsDummy.Select(_ => x) ?? Observable.Return(null)) + .Select(x => x?.IsDummy.Select(_ => x) ?? Observable.ReturnThenNever(null)) .Switch() .Where(v => v?.IsDummy.Value == true) .Take(1) diff --git a/src/Beutl/Views/Editors/PropertyEditorMenu.axaml b/src/Beutl/Views/Editors/PropertyEditorMenu.axaml index 50b02d6b7..c3af4cfcb 100644 --- a/src/Beutl/Views/Editors/PropertyEditorMenu.axaml +++ b/src/Beutl/Views/Editors/PropertyEditorMenu.axaml @@ -41,10 +41,30 @@ Mode=TwoWay}" IsVisible="False" Text="一定な値" /> + + + + + + + diff --git a/src/Beutl/Views/Editors/PropertyEditorMenu.axaml.cs b/src/Beutl/Views/Editors/PropertyEditorMenu.axaml.cs index 5f36466e8..5aaeee3a7 100644 --- a/src/Beutl/Views/Editors/PropertyEditorMenu.axaml.cs +++ b/src/Beutl/Views/Editors/PropertyEditorMenu.axaml.cs @@ -6,6 +6,7 @@ using Beutl.ViewModels; using Beutl.ViewModels.Editors; using Beutl.ViewModels.Tools; +using FluentAvalonia.UI.Controls; using Microsoft.Extensions.DependencyInjection; namespace Beutl.Views.Editors; @@ -16,9 +17,18 @@ public PropertyEditorMenu() { InitializeComponent(); Bind(ToolTip.TipProperty, this.GetObservable(DataContextProperty) - .Select(v => (v as BaseEditorViewModel)?.HasAnimation ?? Observable.Return(false)) - .Switch() - .Select(v => v ? $"- {Message.RightClickToShowMenu}\n- {Message.AnimationIsEnabled}" : null)); + .Select(v => v is BaseEditorViewModel viewModel + ? viewModel.HasAnimation + .CombineLatest(viewModel.HasExpression) + .Select(t => t switch + { + (true, _) => + $"- {Message.RightClickToShowMenu}\n- {Message.AnimationIsEnabled}", + (_, true) => $"- {Message.RightClickToShowMenu}\n- {Message.ExpressionIsSet}", + _ => null + }) + : Observable.ReturnThenNever(null)) + .Switch()); } protected override void OnDataContextChanged(EventArgs e) @@ -26,17 +36,29 @@ protected override void OnDataContextChanged(EventArgs e) base.OnDataContextChanged(e); toggleLivePreview.IsVisible = DataContext is IConfigureLivePreview; uniformEditorToggle.IsVisible = DataContext is IConfigureUniformEditor; + + // 式の編集メニューはIExpressionPropertyAdapterをサポートするプロパティでのみ表示 + bool supportsExpression = DataContext is BaseEditorViewModel { PropertyAdapter: IExpressionPropertyAdapter }; + expressionSeparator.IsVisible = supportsExpression; + editExpressionItem.IsVisible = supportsExpression; + removeExpressionItem.IsVisible = supportsExpression; + + // プロパティパスのコピーはEnginePropertyの場合のみ表示 + bool isEngineProperty = (DataContext as BaseEditorViewModel)?.PropertyAdapter.GetEngineProperty() != null; + copyPropertyPathSeparator.IsVisible = isEngineProperty; + copyPropertyPathItem.IsVisible = isEngineProperty; + copyGetPropertyCodeItem.IsVisible = isEngineProperty; } private void Button_Click(object? sender, RoutedEventArgs e) { if (DataContext is BaseEditorViewModel { IsDisposed: false } viewModel) { - if (!viewModel.HasAnimation.Value && sender is Button button) + if (viewModel.HasExpression.Value) { - button.ContextFlyout?.ShowAt(button); + EditExpression_Click(sender, e); } - else if (viewModel.GetService() is { } editViewModel) + else if (viewModel.HasAnimation.Value && viewModel.GetService() is { } editViewModel) { TimeSpan keyTime = editViewModel.CurrentTime.Value; if (symbolIcon.IsFilled) @@ -48,6 +70,10 @@ private void Button_Click(object? sender, RoutedEventArgs e) viewModel.InsertKeyFrame(keyTime); } } + else if (sender is Button button) + { + button.ContextFlyout?.ShowAt(button); + } } } @@ -95,4 +121,81 @@ private void EditInlineAnimation_Click(object? sender, RoutedEventArgs e) } } } + + private void EditExpression_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is BaseEditorViewModel { IsDisposed: false } viewModel) + { + string? currentExpression = viewModel.GetExpressionString(); + + var flyout = new ExpressionEditorFlyout(); + flyout.Placement = PlacementMode.BottomEdgeAlignedRight; + flyout.ExpressionText = currentExpression ?? ""; + flyout.Confirmed += (_, args) => + { + bool isValid = viewModel.SetExpression(args.ExpressionText, out var error); + args.IsValid = isValid; + args.Error = error; + }; + + flyout.ShowAt(this); + } + } + + private void RemoveExpression_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is BaseEditorViewModel { IsDisposed: false } viewModel) + { + viewModel.RemoveExpression(); + } + } + + private async void CopyPropertyPath_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is BaseEditorViewModel { IsDisposed: false } viewModel + && viewModel.PropertyAdapter.GetEngineProperty() is { } engineProperty + && engineProperty.GetOwnerObject() is { } engineObject + && TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) + { + string propertyPath = $"{{{engineObject.Id}}}.{engineProperty.Name}"; + await clipboard.SetTextAsync(propertyPath); + } + } + + private async void CopyGetPropertyCode_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is BaseEditorViewModel { IsDisposed: false } viewModel + && viewModel.PropertyAdapter.GetEngineProperty() is { } engineProperty + && engineProperty.GetOwnerObject() is { } engineObject + && TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) + { + string typeName = GetTypeAlias(engineProperty.ValueType); + string code = $"GetProperty<{typeName}>(\"{{{engineObject.Id}}}.{engineProperty.Name}\")"; + await clipboard.SetTextAsync(code); + } + } + + private static string GetTypeAlias(Type type) + { + // プリミティブ型のエイリアス + return type switch + { + _ when type == typeof(bool) => "bool", + _ when type == typeof(byte) => "byte", + _ when type == typeof(sbyte) => "sbyte", + _ when type == typeof(char) => "char", + _ when type == typeof(short) => "short", + _ when type == typeof(ushort) => "ushort", + _ when type == typeof(int) => "int", + _ when type == typeof(uint) => "uint", + _ when type == typeof(long) => "long", + _ when type == typeof(ulong) => "ulong", + _ when type == typeof(float) => "float", + _ when type == typeof(double) => "double", + _ when type == typeof(decimal) => "decimal", + _ when type == typeof(string) => "string", + _ when type == typeof(object) => "object", + _ => type.FullName ?? type.Name + }; + } } diff --git a/src/Beutl/Views/ElementScopeView.cs b/src/Beutl/Views/ElementScopeView.cs index 161ae36dd..1d591d08f 100644 --- a/src/Beutl/Views/ElementScopeView.cs +++ b/src/Beutl/Views/ElementScopeView.cs @@ -28,11 +28,11 @@ public ElementScopeView() IObservable dataContext = this.GetObservable(DataContextProperty) .Select(v => v as ElementScopeViewModel); - Bind(MarginProperty, dataContext.Select(v => v?.Margin ?? Observable.Return((Thickness)default)).Switch()); - Bind(WidthProperty, dataContext.Select(v => v?.Width ?? Observable.Return(0d)).Switch()); - Bind(HeightProperty, dataContext.Select(v => v?.Height ?? Observable.Return(0d)).Switch()); + Bind(MarginProperty, dataContext.Select(v => v?.Margin ?? Observable.ReturnThenNever((Thickness)default)).Switch()); + Bind(WidthProperty, dataContext.Select(v => v?.Width ?? Observable.ReturnThenNever(0d)).Switch()); + Bind(HeightProperty, dataContext.Select(v => v?.Height ?? Observable.ReturnThenNever(0d)).Switch()); - Bind(FillProperty, dataContext.Select(v => v?.Parent?.Color ?? Observable.Return(Colors.Transparent)) + Bind(FillProperty, dataContext.Select(v => v?.Parent?.Color ?? Observable.ReturnThenNever(Colors.Transparent)) .Switch() .Select(v => new ImmutableSolidColorBrush(v, 0.1))); } diff --git a/src/Beutl/Views/GraphEditorView.axaml.cs b/src/Beutl/Views/GraphEditorView.axaml.cs index 2e315823d..e58804c6e 100644 --- a/src/Beutl/Views/GraphEditorView.axaml.cs +++ b/src/Beutl/Views/GraphEditorView.axaml.cs @@ -92,7 +92,7 @@ private void OnContainerClearing(object? sender, ContainerClearingEventArgs e) { if (e.Container is { } container) { - container.Bind(ZIndexProperty, Observable.Return(BindingValue.Unset)); + container.Bind(ZIndexProperty, Observable.ReturnThenNever(BindingValue.Unset)); } } diff --git a/src/Beutl/Views/NodeTree/SocketPoint.cs b/src/Beutl/Views/NodeTree/SocketPoint.cs index 198bcdae3..6dcf2849a 100644 --- a/src/Beutl/Views/NodeTree/SocketPoint.cs +++ b/src/Beutl/Views/NodeTree/SocketPoint.cs @@ -56,7 +56,7 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) this.GetResourceObservable("SystemFillColorCautionBrush"), this.GetResourceObservable("SystemFillColorCriticalBrush"), this.GetObservable(InputSocketProperty) - .Select(o => o?.Status ?? Observable.Return(ConnectionStatus.Disconnected)) + .Select(o => o?.Status ?? Observable.ReturnThenNever(ConnectionStatus.Disconnected)) .Switch()) .ObserveOnUIDispatcher() .Subscribe(x => diff --git a/src/Beutl/Views/PathEditor/PathPointDragBehavior.cs b/src/Beutl/Views/PathEditor/PathPointDragBehavior.cs index 5945f7410..7c0bb5687 100644 --- a/src/Beutl/Views/PathEditor/PathPointDragBehavior.cs +++ b/src/Beutl/Views/PathEditor/PathPointDragBehavior.cs @@ -6,6 +6,7 @@ using Avalonia.Xaml.Interactivity; using Beutl.Animation; using Beutl.Engine; +using Beutl.Graphics.Rendering; using Beutl.Media; using Beutl.ViewModels; using Beutl.ViewModels.Editors; @@ -271,7 +272,8 @@ void Set(KeyFrame? keyframe) keyTime += element.Start; } - BtlPoint anchorpoint = anchor.GetEndPoint().GetValue(keyTime); + var ctx = new RenderContext(keyTime); + BtlPoint anchorpoint = anchor.GetEndPoint().GetValue(ctx); BtlPoint point = _dragState.GetInterpolatedValue(keyTime); BtlPoint d = anchorpoint - point; float angle = MathF.Atan2(d.X, d.Y); @@ -300,10 +302,10 @@ void Set(KeyFrame? keyframe) } else { + var ctx = new RenderContext(viewModel.EditViewModel.CurrentTime.Value); BtlPoint point = _dragState.GetInterpolatedValue(viewModel.EditViewModel.CurrentTime.Value); - BtlPoint anchorpoint = anchor.GetEndPoint() - .GetValue(viewModel.EditViewModel.CurrentTime.Value); + BtlPoint anchorpoint = anchor.GetEndPoint().GetValue(ctx); BtlPoint d = anchorpoint - point; float angle = MathF.Atan2(d.X, d.Y); angle -= MathF.PI / 2; diff --git a/src/Beutl/Views/PathEditor/PathPointDragState.cs b/src/Beutl/Views/PathEditor/PathPointDragState.cs index 09071a116..a95fb1fbe 100644 --- a/src/Beutl/Views/PathEditor/PathPointDragState.cs +++ b/src/Beutl/Views/PathEditor/PathPointDragState.cs @@ -4,6 +4,7 @@ using Beutl.Animation; using Beutl.Engine; +using Beutl.Graphics.Rendering; using Beutl.Media; using BtlPoint = Beutl.Graphics.Point; @@ -60,13 +61,15 @@ public BtlPoint GetSampleValue(TimeSpan currentTime) } else { - return Property.GetValue(currentTime); + var ctx = new RenderContext(currentTime); + return Property.GetValue(ctx); } } public BtlPoint GetInterpolatedValue(TimeSpan currentTime) { - return Property.GetValue(currentTime); + var ctx = new RenderContext(currentTime); + return Property.GetValue(ctx); } public void SetValue(BtlPoint point) diff --git a/src/Beutl/Views/PathEditorView.axaml.cs b/src/Beutl/Views/PathEditorView.axaml.cs index ae7ac09db..0d9720514 100644 --- a/src/Beutl/Views/PathEditorView.axaml.cs +++ b/src/Beutl/Views/PathEditorView.axaml.cs @@ -7,6 +7,7 @@ using Avalonia.Threading; using Avalonia.Xaml.Interactivity; using Beutl.Engine; +using Beutl.Graphics.Rendering; using Beutl.Media; using Beutl.ViewModels; @@ -60,7 +61,7 @@ public PathEditorView() this.GetObservable(DataContextProperty) .Select(v => v as PathEditorViewModel) .Select(v => v?.SelectedOperation.CombineLatest(v.IsClosed).ToUnit() - ?? Observable.Return(default)) + ?? Observable.ReturnThenNever(default)) .Switch() .ObserveOnUIDispatcher() .Subscribe(_ => UpdateControlPointVisibility()); @@ -69,7 +70,7 @@ public PathEditorView() // TODO: Scale, Matrixが変わった時に位置がずれる this.GetObservable(DataContextProperty) .Select(v => v as PathEditorViewModel) - .Select(v => v?.PlayerViewModel?.AfterRendered ?? Observable.Return(Unit.Default)) + .Select(v => v?.PlayerViewModel?.AfterRendered ?? Observable.ReturnThenNever(Unit.Default)) .Switch() .CombineLatest(this.GetObservable(ScaleProperty), this.GetObservable(MatrixProperty)) .Subscribe(_ => UpdateThumbPosition()); @@ -134,7 +135,8 @@ public void UpdateThumbPosition() IProperty? prop = PathEditorHelper.GetProperty(thumb); if (prop != null) { - Point point = prop.GetValue(viewModel.EditViewModel.CurrentTime.Value).ToAvaPoint(); + var ctx = new RenderContext(viewModel.EditViewModel.CurrentTime.Value); + Point point = prop.GetValue(ctx).ToAvaPoint(); point = point.Transform(Matrix); point *= Scale; @@ -282,7 +284,8 @@ private void AddOpClicked(object? sender, RoutedEventArgs e) if (index > 0) { PathSegment lastOp = figure.Segments[index - 1]; - lastPoint = lastOp.GetEndPoint().GetValue(editViewModel.CurrentTime.Value); + var ctx = new RenderContext(editViewModel.CurrentTime.Value); + lastPoint = lastOp.GetEndPoint().GetValue(ctx); } BtlPoint point = (_clickPoint / Scale).ToBtlPoint(); diff --git a/src/Beutl/Views/Tools/PathEditorTab.axaml.cs b/src/Beutl/Views/Tools/PathEditorTab.axaml.cs index 41cd330ce..4681535f2 100644 --- a/src/Beutl/Views/Tools/PathEditorTab.axaml.cs +++ b/src/Beutl/Views/Tools/PathEditorTab.axaml.cs @@ -9,6 +9,7 @@ using Avalonia.Threading; using Avalonia.Xaml.Interactivity; using Beutl.Engine; +using Beutl.Graphics.Rendering; using Beutl.Media; using Beutl.ViewModels; using FluentAvalonia.UI.Controls; @@ -71,7 +72,7 @@ public PathEditorTab() this.GetObservable(DataContextProperty) .Select(v => v as PathEditorTabViewModel) .Select(v => v?.SelectedOperation.CombineLatest(v.IsClosed).ToUnit() - ?? Observable.Return(default)) + ?? Observable.ReturnThenNever(default)) .Switch() .ObserveOnUIDispatcher() .Subscribe(_ => UpdateControlPointVisibility()); @@ -79,14 +80,14 @@ public PathEditorTab() // 個別にBindingするのではなく、一括で位置を変更する this.GetObservable(DataContextProperty) .Select(v => v as PathEditorTabViewModel) - .Select(v => v?.EditViewModel.Player.AfterRendered ?? Observable.Return(Unit.Default)) + .Select(v => v?.EditViewModel.Player.AfterRendered ?? Observable.ReturnThenNever(Unit.Default)) .Switch() .CombineLatest(this.GetObservable(ScaleProperty), this.GetObservable(MatrixProperty)) .Subscribe(_ => UpdateThumbPosition()); this.GetObservable(DataContextProperty) .Select(v => v as PathEditorTabViewModel) - .Select(v => v?.EditViewModel.Player.AfterRendered ?? Observable.Return(Unit.Default)) + .Select(v => v?.EditViewModel.Player.AfterRendered ?? Observable.ReturnThenNever(Unit.Default)) .Switch() .CombineLatest(view.GetObservable(PathGeometryControl.FigureProperty)) .Subscribe(_ => UpdateBackgroundGeometry()); @@ -214,7 +215,7 @@ public void UpdateThumbPosition() if (prop != null) { TimeSpan currentTime = viewModel.EditViewModel.CurrentTime.Value; - Point point = prop.GetValue(currentTime).ToAvaPoint(); + Point point = prop.GetValue(new RenderContext(currentTime)).ToAvaPoint(); point = point.Transform(Matrix); point *= Scale; @@ -545,7 +546,7 @@ private void AddOpClicked(object? sender, RoutedEventArgs e) if (index > 0) { PathSegment lastOp = figure.Segments[index - 1]; - lastPoint = lastOp.GetEndPoint().GetValue(editViewModel.CurrentTime.Value); + lastPoint = lastOp.GetEndPoint().GetValue(new RenderContext(editViewModel.CurrentTime.Value)); } BtlPoint point = (_clickPoint / Scale).ToBtlPoint(); diff --git a/tests/Beutl.UnitTests/Beutl.UnitTests.csproj b/tests/Beutl.UnitTests/Beutl.UnitTests.csproj index 2de8264c7..34f426437 100644 --- a/tests/Beutl.UnitTests/Beutl.UnitTests.csproj +++ b/tests/Beutl.UnitTests/Beutl.UnitTests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/Beutl.UnitTests/Engine/Expressions/ExpressionContextTests.cs b/tests/Beutl.UnitTests/Engine/Expressions/ExpressionContextTests.cs new file mode 100644 index 000000000..0bdcfc29e --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Expressions/ExpressionContextTests.cs @@ -0,0 +1,146 @@ +using Beutl.Engine; + +namespace Beutl.UnitTests.Engine.Expressions; + +[TestFixture] +public class ExpressionContextTests +{ + [Test] + public void Constructor_ShouldSetProperties() + { + // Arrange + var time = TimeSpan.FromSeconds(5); + var context = TestHelper.CreateExpressionContext(time); + + // Assert + Assert.That(context.Time, Is.EqualTo(time)); + Assert.That(context.CurrentProperty, Is.Not.Null); + Assert.That(context.PropertyLookup, Is.Not.Null); + } + + [Test] + public void CurrentProperty_CanBeChanged() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var newProperty = Property.Create(0.0); + + // Act + context.CurrentProperty = newProperty; + + // Assert + Assert.That(context.CurrentProperty, Is.EqualTo(newProperty)); + } + + [Test] + public void TryGetPropertyValue_WithInvalidPath_ShouldReturnFalse() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + + // Act + bool result = context.TryGetPropertyValue("invalid-path", out var value); + + // Assert + Assert.That(result, Is.False); + Assert.That(value, Is.EqualTo(default(double))); + } + + [Test] + public void TryGetPropertyValue_WithGuidAndPropertyName_ShouldDelegateToPropertyLookup() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var guid = Guid.NewGuid(); + + // Act + bool result = context.TryGetPropertyValue(guid, "PropertyName", out var value); + + // Assert + Assert.That(result, Is.False); // PropertyLookup returns false for non-existent objects + Assert.That(value, Is.EqualTo(default(double))); + } + + [Test] + public void IsEvaluating_WithoutBeginEvaluation_ShouldReturnFalse() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var property = Property.Create(0.0); + + // Act + bool result = context.IsEvaluating(property); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void IsEvaluating_AfterBeginEvaluation_ShouldReturnTrue() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var property = Property.Create(0.0); + + // Act + context.BeginEvaluation(property); + bool result = context.IsEvaluating(property); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void IsEvaluating_AfterEndEvaluation_ShouldReturnFalse() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var property = Property.Create(0.0); + context.BeginEvaluation(property); + + // Act + context.EndEvaluation(property); + bool result = context.IsEvaluating(property); + + // Assert + Assert.That(result, Is.False); + } + + [Test] + public void EvaluationStack_ShouldTrackMultipleProperties() + { + // Arrange + var property1 = Property.Create(0.0); + var property2 = Property.Create(0.0); + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + + // Act + context.BeginEvaluation(property1); + context.BeginEvaluation(property2); + + // Assert + Assert.That(context.IsEvaluating(property1), Is.True); + Assert.That(context.IsEvaluating(property2), Is.True); + + // Clean up + context.EndEvaluation(property2); + Assert.That(context.IsEvaluating(property1), Is.True); + Assert.That(context.IsEvaluating(property2), Is.False); + + context.EndEvaluation(property1); + Assert.That(context.IsEvaluating(property1), Is.False); + } + + [Test] + public void Time_ShouldBeInheritedFromRenderContext() + { + // Arrange + var time = TimeSpan.FromMilliseconds(1234); + + // Act + var context = TestHelper.CreateExpressionContext(time); + + // Assert + Assert.That(context.Time, Is.EqualTo(time)); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Expressions/ExpressionGlobalsTests.cs b/tests/Beutl.UnitTests/Engine/Expressions/ExpressionGlobalsTests.cs new file mode 100644 index 000000000..4596cce45 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Expressions/ExpressionGlobalsTests.cs @@ -0,0 +1,389 @@ +using Beutl.Engine; +using Beutl.Engine.Expressions; + +namespace Beutl.UnitTests.Engine.Expressions; + +[TestFixture] +public class ExpressionGlobalsTests +{ + [Test] + public void Constructor_WithNullContext_ShouldThrowArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new ExpressionGlobals(null!)); + } + + [Test] + public void Time_ShouldReturnContextTimeInSeconds() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.FromSeconds(5)); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Time, Is.EqualTo(5.0)); + } + + [Test] + public void PI_ShouldReturnMathPI() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.PI, Is.EqualTo(Math.PI)); + } + + [Test] + public void Sin_ShouldCalculateCorrectly() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Sin(0.0), Is.EqualTo(0.0).Within(0.0001)); + Assert.That(globals.Sin(Math.PI / 2), Is.EqualTo(1.0).Within(0.0001)); + } + + [Test] + public void Cos_ShouldCalculateCorrectly() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Cos(0.0), Is.EqualTo(1.0).Within(0.0001)); + Assert.That(globals.Cos(Math.PI), Is.EqualTo(-1.0).Within(0.0001)); + } + + [Test] + public void Sqrt_ShouldCalculateCorrectly() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Sqrt(4.0), Is.EqualTo(2.0).Within(0.0001)); + Assert.That(globals.Sqrt(9.0), Is.EqualTo(3.0).Within(0.0001)); + } + + [Test] + public void Pow_ShouldCalculateCorrectly() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Pow(2.0, 3.0), Is.EqualTo(8.0).Within(0.0001)); + Assert.That(globals.Pow(3.0, 2.0), Is.EqualTo(9.0).Within(0.0001)); + } + + [Test] + public void Abs_ShouldReturnAbsoluteValue() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Abs(-5.0), Is.EqualTo(5.0)); + Assert.That(globals.Abs(5.0), Is.EqualTo(5.0)); + } + + [Test] + public void Min_ShouldReturnMinimumValue() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Min(3.0, 5.0), Is.EqualTo(3.0)); + Assert.That(globals.Min(5.0, 3.0), Is.EqualTo(3.0)); + } + + [Test] + public void Max_ShouldReturnMaximumValue() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Max(3.0, 5.0), Is.EqualTo(5.0)); + Assert.That(globals.Max(5.0, 3.0), Is.EqualTo(5.0)); + } + + [Test] + public void Clamp_ShouldClampValue() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Clamp(5.0, 0.0, 10.0), Is.EqualTo(5.0)); + Assert.That(globals.Clamp(-5.0, 0.0, 10.0), Is.EqualTo(0.0)); + Assert.That(globals.Clamp(15.0, 0.0, 10.0), Is.EqualTo(10.0)); + } + + [Test] + public void Lerp_ShouldInterpolateCorrectly() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Lerp(0.0, 10.0, 0.0), Is.EqualTo(0.0).Within(0.0001)); + Assert.That(globals.Lerp(0.0, 10.0, 0.5), Is.EqualTo(5.0).Within(0.0001)); + Assert.That(globals.Lerp(0.0, 10.0, 1.0), Is.EqualTo(10.0).Within(0.0001)); + } + + [Test] + public void InverseLerp_ShouldCalculateCorrectly() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.InverseLerp(0.0, 10.0, 0.0), Is.EqualTo(0.0).Within(0.0001)); + Assert.That(globals.InverseLerp(0.0, 10.0, 5.0), Is.EqualTo(0.5).Within(0.0001)); + Assert.That(globals.InverseLerp(0.0, 10.0, 10.0), Is.EqualTo(1.0).Within(0.0001)); + } + + [Test] + public void Remap_ShouldRemapValueCorrectly() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert (0-10 -> 0-100) + Assert.That(globals.Remap(5.0, 0.0, 10.0, 0.0, 100.0), Is.EqualTo(50.0).Within(0.0001)); + } + + [Test] + public void Smoothstep_ShouldCalculateCorrectly() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Smoothstep(0.0, 1.0, 0.0), Is.EqualTo(0.0).Within(0.0001)); + Assert.That(globals.Smoothstep(0.0, 1.0, 1.0), Is.EqualTo(1.0).Within(0.0001)); + Assert.That(globals.Smoothstep(0.0, 1.0, 0.5), Is.EqualTo(0.5).Within(0.0001)); + } + + [Test] + public void Radians_ShouldConvertDegreesToRadians() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Radians(180.0), Is.EqualTo(Math.PI).Within(0.0001)); + Assert.That(globals.Radians(90.0), Is.EqualTo(Math.PI / 2).Within(0.0001)); + } + + [Test] + public void Degrees_ShouldConvertRadiansToDegrees() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Degrees(Math.PI), Is.EqualTo(180.0).Within(0.0001)); + Assert.That(globals.Degrees(Math.PI / 2), Is.EqualTo(90.0).Within(0.0001)); + } + + [Test] + public void Floor_ShouldFloorValue() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Floor(3.7), Is.EqualTo(3.0)); + Assert.That(globals.Floor(-3.7), Is.EqualTo(-4.0)); + } + + [Test] + public void Ceil_ShouldCeilValue() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Ceil(3.2), Is.EqualTo(4.0)); + Assert.That(globals.Ceil(-3.2), Is.EqualTo(-3.0)); + } + + [Test] + public void Round_ShouldRoundValue() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Round(3.4), Is.EqualTo(3.0)); + Assert.That(globals.Round(3.6), Is.EqualTo(4.0)); + } + + [Test] + public void Mod_ShouldCalculateModulo() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Mod(5.0, 3.0), Is.EqualTo(2.0).Within(0.0001)); + Assert.That(globals.Mod(7.0, 4.0), Is.EqualTo(3.0).Within(0.0001)); + } + + [Test] + public void Frac_ShouldReturnFractionalPart() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Assert + Assert.That(globals.Frac(3.75), Is.EqualTo(0.75).Within(0.0001)); + Assert.That(globals.Frac(5.25), Is.EqualTo(0.25).Within(0.0001)); + } + + [Test] + public void Random_WithSameSeed_ShouldReturnSameValue() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Act + double value1 = globals.Random(42); + double value2 = globals.Random(42); + + // Assert + Assert.That(value1, Is.EqualTo(value2)); + Assert.That(value1, Is.GreaterThanOrEqualTo(0.0)); + Assert.That(value1, Is.LessThan(1.0)); + } + + [Test] + public void Random_WithDifferentSeeds_ShouldReturnDifferentValues() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Act + double value1 = globals.Random(1); + double value2 = globals.Random(2); + + // Assert + Assert.That(value1, Is.Not.EqualTo(value2)); + } + + [Test] + public void Random_WithRange_ShouldReturnValueInRange() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Act + double value = globals.Random(42, 10.0, 20.0); + + // Assert + Assert.That(value, Is.GreaterThanOrEqualTo(10.0)); + Assert.That(value, Is.LessThan(20.0)); + } + + [Test] + public void GetProperty_WithInvalidPath_ShouldReturnDefault() + { + // Arrange + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + var globals = new ExpressionGlobals(context); + + // Act + double result = globals.GetProperty("invalid-path"); + + // Assert + Assert.That(result, Is.EqualTo(0.0)); + } + + [Test] + public void GetProperty_WithValidPath_ShouldReturnPropertyValue() + { + // Arrange - Create a hierarchy with a property + var root = new TestEngineObject(); + var child = new TestEngineObject(); + child.Value.CurrentValue = 42.0; + root.AddChild(child); + + var lookup = new PropertyLookup(root); + var property = Property.Create(0.0); + var context = new ExpressionContext(TimeSpan.Zero, property, lookup); + var globals = new ExpressionGlobals(context); + + // Act + double result = globals.GetProperty($"{child.Id}.Value"); + + // Assert + Assert.That(result, Is.EqualTo(42.0)); + } + + [Test] + public void GetProperty_WithGuidOverload_ShouldReturnPropertyValue() + { + // Arrange - Create a hierarchy with a property + var root = new TestEngineObject(); + var child = new TestEngineObject(); + child.Value.CurrentValue = 123.5; + root.AddChild(child); + + var lookup = new PropertyLookup(root); + var property = Property.Create(0.0); + var context = new ExpressionContext(TimeSpan.Zero, property, lookup); + var globals = new ExpressionGlobals(context); + + // Act - Using GUID + property name overload + double result = globals.GetProperty(child.Id, "Value"); + + // Assert + Assert.That(result, Is.EqualTo(123.5)); + } + + [Test] + public void GetProperty_WithNonExistentObject_ShouldReturnDefault() + { + // Arrange + var root = new TestEngineObject(); + var lookup = new PropertyLookup(root); + var property = Property.Create(0.0); + var context = new ExpressionContext(TimeSpan.Zero, property, lookup); + var globals = new ExpressionGlobals(context); + var nonExistentGuid = Guid.NewGuid(); + + // Act + double result = globals.GetProperty($"{nonExistentGuid}.SomeProperty"); + + // Assert + Assert.That(result, Is.EqualTo(0.0)); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Expressions/ExpressionTests.cs b/tests/Beutl.UnitTests/Engine/Expressions/ExpressionTests.cs new file mode 100644 index 000000000..f0d10f0b2 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Expressions/ExpressionTests.cs @@ -0,0 +1,218 @@ +using Beutl.Engine.Expressions; + +namespace Beutl.UnitTests.Engine.Expressions; + +[TestFixture] +public class ExpressionTests +{ + [Test] + public void Constructor_WithValidExpression_ShouldNotThrow() + { + // Arrange & Act + var expression = new Expression("1 + 2"); + + // Assert + Assert.That(expression.ExpressionString, Is.EqualTo("1 + 2")); + } + + [Test] + public void Constructor_WithNullExpression_ShouldThrowArgumentNullException() + { + // Arrange & Act & Assert + Assert.Throws(() => new Expression(null!)); + } + + [Test] + public void ResultType_ShouldReturnCorrectType() + { + // Arrange + var expression = new Expression("1.0"); + + // Assert + Assert.That(expression.ResultType, Is.EqualTo(typeof(double))); + } + + [Test] + public void Validate_WithValidExpression_ShouldReturnTrue() + { + // Arrange + var expression = new Expression("1 + 2 * 3"); + + // Act + bool result = expression.Validate(out string? error); + + // Assert + Assert.That(result, Is.True); + Assert.That(error, Is.Null); + } + + [Test] + public void Validate_WithInvalidExpression_ShouldReturnFalse() + { + // Arrange + var expression = new Expression("invalid syntax ++"); + + // Act + bool result = expression.Validate(out string? error); + + // Assert + Assert.That(result, Is.False); + Assert.That(error, Is.Not.Null); + } + + [Test] + public void Evaluate_WithSimpleArithmetic_ShouldReturnCorrectResult() + { + // Arrange + var expression = new Expression("1 + 2 * 3"); + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + + // Act + double result = expression.Evaluate(context); + + // Assert + Assert.That(result, Is.EqualTo(7.0)); + } + + [Test] + public void Evaluate_WithMathFunctions_ShouldWork() + { + // Arrange + var expression = new Expression("Sin(0.0)"); + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + + // Act + double result = expression.Evaluate(context); + + // Assert + Assert.That(result, Is.EqualTo(0.0).Within(0.0001)); + } + + [Test] + public void Evaluate_WithTimeProperty_ShouldWork() + { + // Arrange + var expression = new Expression("Time"); + var context = TestHelper.CreateExpressionContext(TimeSpan.FromSeconds(5)); + + // Act + double result = expression.Evaluate(context); + + // Assert + Assert.That(result, Is.EqualTo(5.0)); + } + + [Test] + public void Evaluate_WithInvalidExpression_ShouldThrowExpressionException() + { + // Arrange + var expression = new Expression("invalid syntax ++"); + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + + // Act & Assert + Assert.Throws(() => expression.Evaluate(context)); + } + + [Test] + public void Evaluate_WithIntReturnType_ShouldConvertToDouble() + { + // Arrange + var expression = new Expression("1 + 2"); + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + + // Act + double result = expression.Evaluate(context); + + // Assert + Assert.That(result, Is.EqualTo(3.0)); + } + + [Test] + public void Evaluate_WithIntExpression_ShouldWork() + { + // Arrange + var expression = new Expression("1 + 2"); + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + + // Act + int result = expression.Evaluate(context); + + // Assert + Assert.That(result, Is.EqualTo(3)); + } + + [Test] + public void Evaluate_WithBoolExpression_ShouldWork() + { + // Arrange + var expression = new Expression("1 > 0"); + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + + // Act + bool result = expression.Evaluate(context); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void Evaluate_WithStringExpression_ShouldWork() + { + // Arrange + var expression = new Expression("\"Hello\""); + var context = TestHelper.CreateExpressionContext(TimeSpan.Zero); + + // Act + string result = expression.Evaluate(context); + + // Assert + Assert.That(result, Is.EqualTo("Hello")); + } + + [Test] + public void ToString_ShouldReturnExpressionString() + { + // Arrange + var expression = new Expression("1 + 2"); + + // Act + string result = expression.ToString(); + + // Assert + Assert.That(result, Is.EqualTo("1 + 2")); + } + + [Test] + public void Create_ShouldCreateExpressionWithCorrectString() + { + // Act + var expression = Expression.Create("1 + 2"); + + // Assert + Assert.That(expression.ExpressionString, Is.EqualTo("1 + 2")); + } + + [Test] + public void TryParse_WithValidExpression_ShouldReturnTrue() + { + // Act + bool result = Expression.TryParse("1 + 2", out var expression, out string? error); + + // Assert + Assert.That(result, Is.True); + Assert.That(expression, Is.Not.Null); + Assert.That(error, Is.Null); + } + + [Test] + public void TryParse_WithInvalidExpression_ShouldReturnFalse() + { + // Act + bool result = Expression.TryParse("invalid ++", out var expression, out string? error); + + // Assert + Assert.That(result, Is.False); + Assert.That(expression, Is.Null); + Assert.That(error, Is.Not.Null); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Expressions/PropertyLookupTests.cs b/tests/Beutl.UnitTests/Engine/Expressions/PropertyLookupTests.cs new file mode 100644 index 000000000..73e351a26 --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Expressions/PropertyLookupTests.cs @@ -0,0 +1,245 @@ +using Beutl.Engine; +using Beutl.Engine.Expressions; + +namespace Beutl.UnitTests.Engine.Expressions; + +[TestFixture] +public class PropertyLookupTests +{ + [Test] + public void Constructor_WithNullRoot_ShouldThrowArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new PropertyLookup(null!)); + } + + [Test] + public void TryGetPropertyValue_WithInvalidPathFormat_ShouldReturnFalse() + { + // Arrange + var root = new TestCoreObject(); + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup); + + // Act (path without dot separator) + bool result = lookup.TryGetPropertyValue("invalidpath", context, out var value); + + // Assert + Assert.That(result, Is.False); + Assert.That(value, Is.EqualTo(default(double))); + } + + [Test] + public void TryGetPropertyValue_WithNonGuidIdentifier_ShouldReturnFalse() + { + // Arrange + var root = new TestCoreObject(); + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup); + + // Act (path with non-GUID identifier) + bool result = lookup.TryGetPropertyValue("not-a-guid.PropertyName", context, out var value); + + // Assert + Assert.That(result, Is.False); + Assert.That(value, Is.EqualTo(default(double))); + } + + [Test] + public void TryGetPropertyValue_WithNonExistentObject_ShouldReturnFalse() + { + // Arrange + var root = new TestCoreObject(); + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup); + var guid = Guid.NewGuid(); + + // Act + bool result = lookup.TryGetPropertyValue($"{guid}.PropertyName", context, out var value); + + // Assert + Assert.That(result, Is.False); + Assert.That(value, Is.EqualTo(default(double))); + } + + [Test] + public void TryGetPropertyValue_WithGuidAndPropertyName_AndNonExistentObject_ShouldReturnFalse() + { + // Arrange + var root = new TestCoreObject(); + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup); + var guid = Guid.NewGuid(); + + // Act + bool result = lookup.TryGetPropertyValue(guid, "PropertyName", context, out var value); + + // Assert + Assert.That(result, Is.False); + Assert.That(value, Is.EqualTo(default(double))); + } + + [Test] + public void TryGetPropertyValue_WithBracedGuid_ShouldWork() + { + // Arrange + var root = new TestCoreObject(); + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup); + var guid = Guid.NewGuid(); + + // Act - Test with braced GUID format {guid} + bool result = lookup.TryGetPropertyValue($"{{{guid}}}.PropertyName", context, out var value); + + // Assert - Should be false because object doesn't exist, but should parse correctly + Assert.That(result, Is.False); + } + + [Test] + public void TryGetPropertyValue_WithEmptyPropertyName_ShouldReturnFalse() + { + // Arrange + var root = new TestCoreObject(); + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup); + var guid = Guid.NewGuid(); + + // Act - Path with empty property name + bool result = lookup.TryGetPropertyValue($"{guid}.", context, out var value); + + // Assert - Returns false because the object is not found (GUID doesn't exist in the hierarchy) + Assert.That(result, Is.False); + } + + [Test] + public void TryGetPropertyValue_WithExistingEngineObject_ShouldReturnPropertyValue() + { + // Arrange - Create a root with a child EngineObject + var root = new TestEngineObject(); + var child = new TestEngineObject(); + child.Value.CurrentValue = 42.0; + root.AddChild(child); + + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup, TimeSpan.Zero); + + // Act - Get the property value using the child's GUID + bool result = lookup.TryGetPropertyValue($"{child.Id}.Value", context, out var value); + + // Assert + Assert.That(result, Is.True); + Assert.That(value, Is.EqualTo(42.0)); + } + + [Test] + public void TryGetPropertyValue_WithExistingEngineObject_UsingGuidOverload_ShouldReturnPropertyValue() + { + // Arrange - Create a root with a child EngineObject + var root = new TestEngineObject(); + var child = new TestEngineObject(); + child.Value.CurrentValue = 99.5; + root.AddChild(child); + + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup, TimeSpan.Zero); + + // Act - Get the property value using the GUID overload + bool result = lookup.TryGetPropertyValue(child.Id, "Value", context, out var value); + + // Assert + Assert.That(result, Is.True); + Assert.That(value, Is.EqualTo(99.5)); + } + + [Test] + public void TryGetPropertyValue_WithExistingEngineObject_PropertyNameCaseInsensitive_ShouldReturnPropertyValue() + { + // Arrange - Create a root with a child EngineObject + var root = new TestEngineObject(); + var child = new TestEngineObject(); + child.Value.CurrentValue = 123.0; + root.AddChild(child); + + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup, TimeSpan.Zero); + + // Act - Get the property with different casing (lowercase) + bool result = lookup.TryGetPropertyValue($"{child.Id}.value", context, out var value); + + // Assert - Should work because PropertyLookup uses OrdinalIgnoreCase comparison + Assert.That(result, Is.True); + Assert.That(value, Is.EqualTo(123.0)); + } + + [Test] + public void TryGetPropertyValue_WithExistingEngineObject_NonExistentProperty_ShouldReturnFalse() + { + // Arrange - Create a root with a child EngineObject + var root = new TestEngineObject(); + var child = new TestEngineObject(); + child.Value.CurrentValue = 42.0; + root.AddChild(child); + + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup, TimeSpan.Zero); + + // Act - Try to get a non-existent property + bool result = lookup.TryGetPropertyValue($"{child.Id}.NonExistent", context, out var value); + + // Assert + Assert.That(result, Is.False); + Assert.That(value, Is.EqualTo(default(double))); + } + + [Test] + public void TryGetPropertyValue_WithRootObject_ShouldReturnPropertyValue() + { + // Arrange - Use the root object directly (includeSelf = true in FindById) + var root = new TestEngineObject(); + root.Value.CurrentValue = 77.7; + + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup, TimeSpan.Zero); + + // Act - Get property from root itself + bool result = lookup.TryGetPropertyValue($"{root.Id}.Value", context, out var value); + + // Assert + Assert.That(result, Is.True); + Assert.That(value, Is.EqualTo(77.7)); + } + + [Test] + public void TryGetPropertyValue_WithDeepNestedObject_ShouldReturnPropertyValue() + { + // Arrange - Create a deep hierarchy: root -> child -> grandchild + var root = new TestEngineObject(); + var child = new TestEngineObject(); + var grandchild = new TestEngineObject(); + grandchild.Value.CurrentValue = 999.0; + + root.AddChild(child); + child.AddChild(grandchild); + + var lookup = new PropertyLookup(root); + var context = CreateContext(lookup, TimeSpan.Zero); + + // Act - Get property from deeply nested object + bool result = lookup.TryGetPropertyValue($"{grandchild.Id}.Value", context, out var value); + + // Assert + Assert.That(result, Is.True); + Assert.That(value, Is.EqualTo(999.0)); + } + + private ExpressionContext CreateContext(PropertyLookup lookup) + { + return CreateContext(lookup, TimeSpan.Zero); + } + + private ExpressionContext CreateContext(PropertyLookup lookup, TimeSpan time) + { + var property = Property.Create(0.0); + return new ExpressionContext(time, property, lookup); + } +} diff --git a/tests/Beutl.UnitTests/Engine/Expressions/TestHelper.cs b/tests/Beutl.UnitTests/Engine/Expressions/TestHelper.cs new file mode 100644 index 000000000..ce476cdca --- /dev/null +++ b/tests/Beutl.UnitTests/Engine/Expressions/TestHelper.cs @@ -0,0 +1,41 @@ +using Beutl.Engine; +using Beutl.Engine.Expressions; + +namespace Beutl.UnitTests.Engine.Expressions; + +public static class TestHelper +{ + public static ExpressionContext CreateExpressionContext(TimeSpan time) + { + var root = new TestCoreObject(); + var propertyLookup = new PropertyLookup(root); + var property = Property.Create(0.0); + return new ExpressionContext(time, property, propertyLookup); + } + + public static ExpressionContext CreateExpressionContext(TimeSpan time, IProperty property) + { + var root = new TestCoreObject(); + var propertyLookup = new PropertyLookup(root); + return new ExpressionContext(time, property, propertyLookup); + } +} + +public class TestCoreObject : CoreObject +{ +} + +public partial class TestEngineObject : EngineObject +{ + public TestEngineObject() + { + ScanProperties(); + } + + public IProperty Value { get; } = Property.Create(0.0); + + public void AddChild(IHierarchical child) + { + HierarchicalChildren.Add(child); + } +}