diff --git a/osu.Framework.Tests/Bindables/BindableExtensionTest.cs b/osu.Framework.Tests/Bindables/BindableExtensionTest.cs new file mode 100644 index 0000000000..bb6884084c --- /dev/null +++ b/osu.Framework.Tests/Bindables/BindableExtensionTest.cs @@ -0,0 +1,507 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Extensions; + +// ReSharper disable AccessToModifiedClosure +namespace osu.Framework.Tests.Bindables +{ + [TestFixture] + public class BindableExtensionTest + { + [Test] + public void TestMappedBindable() + { + var source = new Bindable(); + + var mapped1 = source.Map(v => v.ToString()); + var mapped2 = source.Map(v => v * 2); + + int changed1 = 0; + int changed2 = 0; + + mapped1.ValueChanged += _ => changed1++; + mapped2.ValueChanged += _ => changed2++; + + Assert.AreEqual(mapped1.Value, "0"); + + source.Value = 3; + + Assert.AreEqual(mapped1.Value, "3"); + Assert.AreEqual(changed1, 1); + + Assert.AreEqual(mapped2.Value, 6); + Assert.AreEqual(changed2, 1); + + source.Value = -10; + + Assert.AreEqual(mapped1.Value, "-10"); + Assert.AreEqual(changed1, 2); + + Assert.AreEqual(mapped2.Value, -20); + Assert.AreEqual(changed2, 2); + + source.Disabled = true; + Assert.IsTrue(mapped1.Disabled); + Assert.IsTrue(mapped2.Disabled); + + source.Disabled = false; + Assert.IsFalse(mapped1.Disabled); + Assert.IsFalse(mapped2.Disabled); + } + + [Test] + public void TestSyncedBindable() + { + var source = new Bindable(); + var dest = new Bindable(); + + int sourceChanged = 0; + int destChanged = 0; + + source.ValueChanged += _ => sourceChanged++; + dest.ValueChanged += _ => destChanged++; + + dest.SyncWith(source, value => value.ToString(), int.Parse); + + Assert.AreEqual(0, source.Value); + Assert.AreEqual("0", dest.Value); + Assert.AreEqual(0, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + source.Value = 5; + + Assert.AreEqual(5, source.Value); + Assert.AreEqual("5", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + dest.Value = "-10"; + + Assert.AreEqual(-10, source.Value); + Assert.AreEqual("-10", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(1, destChanged); + + void resetCount() + { + sourceChanged = 0; + destChanged = 0; + } + } + + [Test] + public void TestSyncedDisabledState() + { + var source = new Bindable(); + var dest = new Bindable(); + + dest.SyncWith(source, value => value.ToString(), int.Parse); + + int sourceDisabledChanged = 0; + int destDisabledChanged = 0; + + source.DisabledChanged += _ => sourceDisabledChanged++; + dest.DisabledChanged += _ => destDisabledChanged++; + + source.Disabled = true; + + Assert.IsTrue(source.Disabled); + Assert.IsTrue(dest.Disabled); + Assert.AreEqual(sourceDisabledChanged, 1); + Assert.AreEqual(destDisabledChanged, 1); + + source.Disabled = false; + + Assert.IsFalse(source.Disabled); + Assert.IsFalse(dest.Disabled); + Assert.AreEqual(sourceDisabledChanged, 2); + Assert.AreEqual(destDisabledChanged, 2); + + dest.Disabled = true; + + Assert.IsTrue(source.Disabled); + Assert.IsTrue(dest.Disabled); + Assert.AreEqual(sourceDisabledChanged, 3); + Assert.AreEqual(destDisabledChanged, 3); + + dest.Disabled = false; + + Assert.IsFalse(source.Disabled); + Assert.IsFalse(dest.Disabled); + Assert.AreEqual(sourceDisabledChanged, 4); + Assert.AreEqual(destDisabledChanged, 4); + } + + [Test] + public void TestSafeSyncedBindable() + { + var source = new Bindable(); + var dest = new Bindable(); + + int sourceChanged = 0; + int destChanged = 0; + + source.ValueChanged += _ => sourceChanged++; + dest.ValueChanged += _ => destChanged++; + + dest.SyncWith(source, value => value.ToString(), int.TryParse); + + Assert.AreEqual(0, source.Value); + Assert.AreEqual("0", dest.Value); + Assert.AreEqual(0, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + source.Value = 5; + + Assert.AreEqual(5, source.Value); + Assert.AreEqual("5", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + dest.Value = "-10"; + + Assert.AreEqual(-10, source.Value); + Assert.AreEqual("-10", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + dest.Value = "invalid value"; + + Assert.AreEqual(-10, source.Value); + Assert.AreEqual("-10", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(2, destChanged); + + void resetCount() + { + sourceChanged = 0; + destChanged = 0; + } + } + + [Test] + public void TestAsymmetricSync() + { + var source = new BindableInt(); + var dest = new BindableInt(); + + dest.SyncWith(source, toDest: v => v * 2, toSource: v => v / 4); + + source.Value = 10; + + Assert.AreEqual(10, source.Value); + Assert.AreEqual(20, dest.Value); + + // When the toSource mapping function returns a value that isn't the exact inverse of the toDest mapping, the dest value's final state should be based on the new source value + dest.Value = 80; + Assert.AreEqual(20, source.Value); + Assert.AreEqual(40, dest.Value); + } + + [Test] + public void TestSafeSyncedDisabledState() + { + var source = new Bindable(); + var dest = new Bindable(); + + dest.SyncWith(source, value => value.ToString(), int.TryParse); + + int sourceDisabledChanged = 0; + int destDisabledChanged = 0; + + source.DisabledChanged += _ => sourceDisabledChanged++; + dest.DisabledChanged += _ => destDisabledChanged++; + + source.Disabled = true; + + Assert.IsTrue(source.Disabled); + Assert.IsTrue(dest.Disabled); + Assert.AreEqual(sourceDisabledChanged, 1); + Assert.AreEqual(destDisabledChanged, 1); + + source.Disabled = false; + + Assert.IsFalse(source.Disabled); + Assert.IsFalse(dest.Disabled); + Assert.AreEqual(sourceDisabledChanged, 2); + Assert.AreEqual(destDisabledChanged, 2); + + dest.Disabled = true; + + Assert.IsTrue(source.Disabled); + Assert.IsTrue(dest.Disabled); + Assert.AreEqual(sourceDisabledChanged, 3); + Assert.AreEqual(destDisabledChanged, 3); + + dest.Disabled = false; + + Assert.IsFalse(source.Disabled); + Assert.IsFalse(dest.Disabled); + Assert.AreEqual(sourceDisabledChanged, 4); + Assert.AreEqual(destDisabledChanged, 4); + } + + [Test] + public void TestSyncWithInt() + { + var source = new BindableInt(); + var dest = new Bindable(); + + int sourceChanged = 0; + int destChanged = 0; + + source.ValueChanged += _ => sourceChanged++; + dest.ValueChanged += _ => destChanged++; + + dest.SyncWith(source); + + Assert.AreEqual(0, source.Value); + Assert.AreEqual("0", dest.Value); + Assert.AreEqual(0, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + source.Value = 5; + + Assert.AreEqual(5, source.Value); + Assert.AreEqual("5", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + dest.Value = "-10"; + + Assert.AreEqual(-10, source.Value); + Assert.AreEqual("-10", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + dest.Value = "invalid value"; + + Assert.AreEqual(-10, source.Value); + Assert.AreEqual("-10", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(2, destChanged); + + resetCount(); + + source.MaxValue = 10; + dest.Value = "20"; + + Assert.AreEqual(10, source.Value); + Assert.AreEqual("10", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(2, destChanged); + + void resetCount() + { + sourceChanged = 0; + destChanged = 0; + } + } + + [Test] + public void TestSyncWithFloat() + { + var source = new BindableFloat(); + var dest = new Bindable(); + + int sourceChanged = 0; + int destChanged = 0; + + source.ValueChanged += _ => sourceChanged++; + dest.ValueChanged += _ => destChanged++; + + dest.SyncWith(source); + + Assert.AreEqual(0, source.Value); + Assert.AreEqual("0", dest.Value); + Assert.AreEqual(0, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + source.Value = 5.3f; + + Assert.AreEqual(5.3f, source.Value); + Assert.AreEqual("5.3", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + dest.Value = "-10.9"; + + Assert.AreEqual(-10.9f, source.Value); + Assert.AreEqual("-10.9", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + dest.Value = "invalid value"; + Assert.AreEqual(-10.9f, source.Value); + Assert.AreEqual("-10.9", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(2, destChanged); + + resetCount(); + + source.MaxValue = 10.2f; + dest.Value = "20"; + + Assert.AreEqual(10.2f, source.Value); + Assert.AreEqual("10.2", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(2, destChanged); + + resetCount(); + + source.Precision = 0.01f; + dest.Value = Math.PI.ToString(CultureInfo.InvariantCulture); + + Assert.AreEqual(3.14f, source.Value); + Assert.AreEqual("3.14", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(2, destChanged); + + void resetCount() + { + sourceChanged = 0; + destChanged = 0; + } + } + + [Test] + public void TestSyncWithDouble() + { + var source = new BindableDouble(); + var dest = new Bindable(); + + int sourceChanged = 0; + int destChanged = 0; + + source.ValueChanged += _ => sourceChanged++; + dest.ValueChanged += _ => destChanged++; + + dest.SyncWith(source); + + Assert.AreEqual(0, source.Value); + Assert.AreEqual("0", dest.Value); + Assert.AreEqual(0, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + source.Value = 5.3; + + Assert.AreEqual(5.3, source.Value); + Assert.AreEqual("5.3", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + dest.Value = "-10.9"; + + Assert.AreEqual(-10.9, source.Value); + Assert.AreEqual("-10.9", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(1, destChanged); + + resetCount(); + + dest.Value = "invalid value"; + Assert.AreEqual(-10.9, source.Value); + Assert.AreEqual("-10.9", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(2, destChanged); + + resetCount(); + + source.MaxValue = 10.2; + dest.Value = "20"; + + Assert.AreEqual(10.2, source.Value); + Assert.AreEqual("10.2", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(2, destChanged); + + resetCount(); + + source.Precision = 0.01; + dest.Value = Math.PI.ToString(CultureInfo.InvariantCulture); + + Assert.AreEqual(3.14, source.Value); + Assert.AreEqual("3.14", dest.Value); + Assert.AreEqual(1, sourceChanged); + Assert.AreEqual(2, destChanged); + + void resetCount() + { + sourceChanged = 0; + destChanged = 0; + } + } + + [Test] + public void TestIntFormatting() + { + var source = new BindableInt(); + var dest = new Bindable(); + + dest.SyncWith(source, format: "N0", style: NumberStyles.Integer | NumberStyles.AllowThousands); + + Assert.AreEqual(dest.Value, "0"); + + source.Value = 1234; + Assert.AreEqual(dest.Value, "1,234"); + + dest.Value = "1234567"; + Assert.AreEqual(1234567, source.Value); + Assert.AreEqual("1,234,567", dest.Value); + + dest.Value = "981,412"; + Assert.AreEqual(981412, source.Value); + } + + [Test] + public void TestFloatFormatting() + { + var source = new BindableFloat(); + var dest = new Bindable(); + + dest.SyncWith(source, format: "F2", style: NumberStyles.Float, CultureInfo.InvariantCulture); + + Assert.AreEqual("0.00", dest.Value); + + source.Value = MathF.PI; + + Assert.AreEqual("3.14", dest.Value); + + dest.Value = "1.23456"; + + Assert.AreEqual(1.23456f, source.Value); + Assert.AreEqual("1.23", dest.Value); + } + } +} diff --git a/osu.Framework/Extensions/BindableExtensions.cs b/osu.Framework/Extensions/BindableExtensions.cs new file mode 100644 index 0000000000..837b107c7c --- /dev/null +++ b/osu.Framework/Extensions/BindableExtensions.cs @@ -0,0 +1,170 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using osu.Framework.Bindables; + +namespace osu.Framework.Extensions +{ + public static class BindableExtensions + { + /// + /// Creates a readonly with its value automatically assigned from the source and converted using the given transform function. + /// + public static IBindable Map(this IBindable source, Func transform) + { + var dest = new Bindable(); + + dest.ComputeFrom(source, transform); + + return dest; + } + + /// + /// Binds a to another with the value automatically converted using the given transform function. + /// + public static void ComputeFrom(this Bindable dest, IBindable source, Func transform) + { + source.BindValueChanged(e => + { + dest.Value = transform(e.NewValue); + }, true); + + source.BindDisabledChanged(disabled => + { + dest.Disabled = disabled; + }, true); + } + + /// + /// Bidirectionally syncs the value of two s with the two given transform functions. + /// + public static void SyncWith(this Bindable dest, Bindable source, Func toDest, Func toSource) + { + // If the two mapping functions don't deterministically produce the exact inverse result, this could lead to endless recursive updates + // In that case the source value should take precedence + bool isWritingDestValue = false; + + source.BindValueChanged(e => + { + try + { + isWritingDestValue = true; + + dest.Value = toDest(e.NewValue); + } + finally + { + isWritingDestValue = false; + } + }, true); + + source.BindDisabledChanged(disabled => + { + dest.Disabled = disabled; + }, true); + + dest.BindValueChanged(e => + { + if (isWritingDestValue) + return; + + source.Value = toSource(e.NewValue); + }); + + dest.BindDisabledChanged(disabled => + { + source.Disabled = disabled; + }); + } + + public delegate bool SafeMappingFunction(TSource value, [MaybeNullWhen(false)] out TDest result); + + /// + /// Bidirectionally syncs the value of two s with the two given transform functions, with the ability to + /// reset the state based on the source if the destination 's value becomes invalid. + /// + public static void SyncWith(this Bindable dest, Bindable source, Func toDest, SafeMappingFunction tryParse) + { + // If the two mapping functions don't deterministically produce the exact inverse result, this could lead to endless recursive updates + // In that case the source value should take precedence + bool isWritingDestValue = false; + + source.BindValueChanged(e => + { + try + { + isWritingDestValue = true; + + dest.Value = toDest(e.NewValue); + } + finally + { + isWritingDestValue = false; + } + }, true); + + source.BindDisabledChanged(disabled => + { + dest.Disabled = disabled; + }, true); + + dest.BindValueChanged(e => + { + if (isWritingDestValue) + return; + + if (tryParse(e.NewValue, out var result)) + source.Value = result; + else + source.TriggerChange(); + }); + + dest.BindDisabledChanged(disabled => + { + source.Disabled = disabled; + }); + } + + /// + /// Bidirectionally syncs the value of a with a . + /// + public static void SyncWith( + this Bindable dest, Bindable source, + [StringSyntax("NumericFormat")] string? format = null, + NumberStyles style = NumberStyles.Integer, + IFormatProvider? formatProvider = null + ) + { + dest.SyncWith(source, value => value.ToString(format, formatProvider), (string str, out int result) => int.TryParse(str, style, formatProvider, out result)); + } + + /// + /// Bidirectionally syncs the value of a with a . + /// + public static void SyncWith( + this Bindable dest, Bindable source, + [StringSyntax("NumericFormat")] string? format = null, + NumberStyles style = NumberStyles.Float, + IFormatProvider? formatProvider = null + ) + { + dest.SyncWith(source, value => value.ToString(format, formatProvider), (string str, out float result) => float.TryParse(str, style, formatProvider, out result)); + } + + /// + /// Bidirectionally syncs the value of a with a . + /// + public static void SyncWith( + this Bindable dest, Bindable source, + [StringSyntax("NumericFormat")] string? format = null, + NumberStyles style = NumberStyles.Float, + IFormatProvider? formatProvider = null + ) + { + dest.SyncWith(source, value => value.ToString(format, formatProvider), (string str, out double result) => double.TryParse(str, style, formatProvider, out result)); + } + } +}