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