diff --git a/samples/TestApplicationShared/GalleryStyle.cs b/samples/TestApplicationShared/GalleryStyle.cs index ecac60c33a..b29e22d2ec 100644 --- a/samples/TestApplicationShared/GalleryStyle.cs +++ b/samples/TestApplicationShared/GalleryStyle.cs @@ -19,7 +19,7 @@ namespace TestApplication { this.AddRange(new[] { - new Style (s => s.Class(":container").OfType ()) + new Style (s => s.Class("container").OfType ()) { Setters = new[] { @@ -27,7 +27,7 @@ namespace TestApplication } }, - new Style(s => s.Class(":container").OfType().Child().Child().Child().Child().Child().OfType()) + new Style(s => s.Class("container").OfType().Child().Child().Child().Child().Child().OfType()) { Setters = new[] { diff --git a/samples/TestApplicationShared/MainWindow.cs b/samples/TestApplicationShared/MainWindow.cs index 7e05c01a35..e702b6f4a5 100644 --- a/samples/TestApplicationShared/MainWindow.cs +++ b/samples/TestApplicationShared/MainWindow.cs @@ -104,7 +104,7 @@ namespace TestApplication }; - container.Classes.Add(":container"); + container.Classes.Add("container"); window.Show(); return window; diff --git a/samples/XamlTestApplicationPcl/Views/MainWindow.paml b/samples/XamlTestApplicationPcl/Views/MainWindow.paml index c7680382d4..c19220deb7 100644 --- a/samples/XamlTestApplicationPcl/Views/MainWindow.paml +++ b/samples/XamlTestApplicationPcl/Views/MainWindow.paml @@ -5,11 +5,11 @@ Title="Perspex Test Application" Width="800" Height="600"> - + - + diff --git a/src/Perspex.Base/Collections/PerspexList.cs b/src/Perspex.Base/Collections/PerspexList.cs index 120f62d82f..3cb3b34ad0 100644 --- a/src/Perspex.Base/Collections/PerspexList.cs +++ b/src/Perspex.Base/Collections/PerspexList.cs @@ -248,6 +248,16 @@ namespace Perspex.Collections return _inner.GetEnumerator(); } + /// + /// Gets a range of items from the collection. + /// + /// The first index to remove. + /// The number of items to remove. + public IEnumerable GetRange(int index, int count) + { + return _inner.GetRange(index, count); + } + /// /// Gets the index of the specified item in the collection. /// diff --git a/src/Perspex.Controls/Button.cs b/src/Perspex.Controls/Button.cs index c334007b70..71dbbf22e0 100644 --- a/src/Perspex.Controls/Button.cs +++ b/src/Perspex.Controls/Button.cs @@ -215,7 +215,7 @@ namespace Perspex.Controls { base.OnPointerPressed(e); - Classes.Add(":pressed"); + PseudoClasses.Add(":pressed"); e.Device.Capture(this); e.Handled = true; @@ -231,7 +231,7 @@ namespace Perspex.Controls base.OnPointerReleased(e); e.Device.Capture(null); - Classes.Remove(":pressed"); + PseudoClasses.Remove(":pressed"); e.Handled = true; if (ClickMode == ClickMode.Release && Classes.Contains(":pointerover")) diff --git a/src/Perspex.Controls/Classes.cs b/src/Perspex.Controls/Classes.cs index 7a0d79de2b..61f4e8368d 100644 --- a/src/Perspex.Controls/Classes.cs +++ b/src/Perspex.Controls/Classes.cs @@ -1,39 +1,253 @@ // Copyright (c) The Perspex Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using System; using System.Collections.Generic; using System.Linq; using Perspex.Collections; namespace Perspex.Controls { - public class Classes : PerspexList + /// + /// Holds a collection of style classes for an . + /// + /// + /// Similar to CSS, each control may have any number of styling classes applied. + /// + public class Classes : PerspexList, IPseudoClasses { + /// + /// Initializes a new instance of the class. + /// public Classes() - { + { } + /// + /// Initializes a new instance of the class. + /// + /// The initial items. public Classes(IEnumerable items) : base(items) { } + /// + /// Initializes a new instance of the class. + /// + /// The initial items. public Classes(params string[] items) : base(items) { } - public override void Add(string item) + /// + /// Adds a style class to the collection. + /// + /// The class name. + /// + /// Only standard classes may be added via this method. To add pseudoclasses (classes + /// beginning with a ':' character) use the protected + /// property. + /// + public override void Add(string name) + { + ThrowIfPseudoclass(name, "added"); + + if (!Contains(name)) + { + base.Add(name); + } + } + + /// + /// Adds a style classes to the collection. + /// + /// The class names. + /// + /// Only standard classes may be added via this method. To add pseudoclasses (classes + /// beginning with a ':' character) use the protected + /// property. + /// + public override void AddRange(IEnumerable names) + { + var c = new List(); + + foreach (var name in names) + { + ThrowIfPseudoclass(name, "added"); + + if (!Contains(name)) + { + c.Add(name); + } + } + + base.AddRange(c); + } + + /// + /// Inserts a style class into the collection. + /// + /// The index to insert the class at. + /// The class name. + /// + /// Only standard classes may be added via this method. To add pseudoclasses (classes + /// beginning with a ':' character) use the protected + /// property. + /// + public override void Insert(int index, string name) + { + ThrowIfPseudoclass(name, "added"); + + if (!Contains(name)) + { + base.Insert(index, name); + } + } + + /// + /// Inserts style classes into the collection. + /// + /// The index to insert the class at. + /// The class names. + /// + /// Only standard classes may be added via this method. To add pseudoclasses (classes + /// beginning with a ':' character) use the protected + /// property. + /// + public override void InsertRange(int index, IEnumerable names) + { + var c = new List(); + + foreach (var name in names) + { + ThrowIfPseudoclass(name, "added"); + + if (!Contains(name)) + { + c.Add(name); + } + } + + base.InsertRange(index, c); + } + + /// + /// Removes a style class from the collection. + /// + /// The class name. + /// + /// Only standard classes may be removed via this method. To remove pseudoclasses (classes + /// beginning with a ':' character) use the protected + /// property. + /// + public override bool Remove(string name) + { + ThrowIfPseudoclass(name, "removed"); + return base.Remove(name); + } + + /// + /// Removes style classes from the collection. + /// + /// The class name. + /// + /// Only standard classes may be removed via this method. To remove pseudoclasses (classes + /// beginning with a ':' character) use the protected + /// property. + /// + public override void RemoveAll(IEnumerable names) { - if (!Contains(item)) + var c = new List(); + + foreach (var name in names) { - base.Add(item); + ThrowIfPseudoclass(name, "removed"); + + if (!Contains(name)) + { + c.Add(name); + } } + + base.RemoveAll(c); + } + + /// + /// Removes a style class from the collection. + /// + /// The index of the class in the collection. + /// + /// Only standard classes may be removed via this method. To remove pseudoclasses (classes + /// beginning with a ':' character) use the protected + /// property. + /// + public override void RemoveAt(int index) + { + var name = this[index]; + ThrowIfPseudoclass(name, "removed"); + base.RemoveAt(index); } - public override void AddRange(IEnumerable items) + /// + /// Removes style classes from the collection. + /// + /// The first index to remove. + /// The number of items to remove. + public override void RemoveRange(int index, int count) { - base.AddRange(items.Where(x => !Contains(x))); + var names = GetRange(index, count); + base.RemoveRange(index, count); + } + + /// + /// Removes all non-pseudoclasses in the collection and adds a new set. + /// + /// The new contents of the collection. + public void Replace(IList source) + { + var toRemove = new List(); + + foreach (var name in source) + { + ThrowIfPseudoclass(name, "added"); + } + + foreach (var name in this) + { + if (!name.StartsWith(":")) + { + toRemove.Add(name); + } + } + + base.RemoveAll(toRemove); + base.AddRange(source); + } + + /// + void IPseudoClasses.Add(string name) + { + if (!Contains(name)) + { + base.Add(name); + } + } + + /// + bool IPseudoClasses.Remove(string name) + { + return base.Remove(name); + } + + private void ThrowIfPseudoclass(string name, string operation) + { + if (name.StartsWith(":")) + { + throw new ArgumentException( + $"The pseudoclass '{name}' may only be {operation} by the control itself."); + } } } } diff --git a/src/Perspex.Controls/Control.cs b/src/Perspex.Controls/Control.cs index ea20401142..e568b37244 100644 --- a/src/Perspex.Controls/Control.cs +++ b/src/Perspex.Controls/Control.cs @@ -146,8 +146,7 @@ namespace Perspex.Controls { if (_classes != value) { - _classes.Clear(); - _classes.AddRange(value); + _classes.Replace(value); } } } @@ -307,6 +306,12 @@ namespace Perspex.Controls } } + /// + /// Gets the collection in a form that allows adding and removing + /// pseudoclasses. + /// + protected IPseudoClasses PseudoClasses => Classes; + /// /// Sets the control's logical parent. /// @@ -382,11 +387,11 @@ namespace Perspex.Controls { if (selector((T)e.NewValue)) { - ((Control)e.Sender).Classes.Add(className); + ((Control)e.Sender).PseudoClasses.Add(className); } else { - ((Control)e.Sender).Classes.Remove(className); + ((Control)e.Sender).PseudoClasses.Remove(className); } }); } diff --git a/src/Perspex.Controls/ControlExtensions.cs b/src/Perspex.Controls/ControlExtensions.cs index 6667810f24..5948331652 100644 --- a/src/Perspex.Controls/ControlExtensions.cs +++ b/src/Perspex.Controls/ControlExtensions.cs @@ -70,5 +70,35 @@ namespace Perspex.Controls .Select(x => (x as INameScope) ?? NameScope.GetNameScope(x)) .FirstOrDefault(x => x != null); } + + /// + /// Adds or removes a pseudoclass depending on a boolean value. + /// + /// The pseudoclasses collection. + /// The name of the pseudoclass to set. + /// True to add the pseudoclass or false to remove. + public static void Set(this IPseudoClasses classes, string name, bool value) + { + if (value) + { + classes.Add(name); + } + else + { + classes.Remove(name); + } + } + + /// + /// Sets a pseudoclass depending on an observable trigger. + /// + /// The pseudoclasses collection. + /// The name of the pseudoclass to set. + /// The trigger: true adds the pseudoclass, false removes. + /// A disposable used to cancel the subscription. + public static IDisposable Set(this IPseudoClasses classes, string name, IObservable trigger) + { + return trigger.Subscribe(x => classes.Set(name, x)); + } } } diff --git a/src/Perspex.Controls/IPseudoClasses.cs b/src/Perspex.Controls/IPseudoClasses.cs new file mode 100644 index 0000000000..87e5a9259a --- /dev/null +++ b/src/Perspex.Controls/IPseudoClasses.cs @@ -0,0 +1,25 @@ +// Copyright (c) The Perspex Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; + +namespace Perspex.Controls +{ + /// + /// Exposes an interface for setting pseudoclasses on a collection. + /// + public interface IPseudoClasses + { + /// + /// Adds a pseudoclass to the collection. + /// + /// The pseudoclass name. + void Add(string name); + + /// + /// Removes a pseudoclass from the collection. + /// + /// The pseudoclass name. + bool Remove(string name); + } +} diff --git a/src/Perspex.Controls/ItemsControl.cs b/src/Perspex.Controls/ItemsControl.cs index 017434bfac..9a427ae59b 100644 --- a/src/Perspex.Controls/ItemsControl.cs +++ b/src/Perspex.Controls/ItemsControl.cs @@ -64,7 +64,7 @@ namespace Perspex.Controls /// public ItemsControl() { - Classes.Add(":empty"); + PseudoClasses.Add(":empty"); SubscribeToItems(_items); } @@ -302,15 +302,7 @@ namespace Perspex.Controls } var collection = sender as ICollection; - - if (collection.Count == 0) - { - Classes.Add(":empty"); - } - else - { - Classes.Remove(":empty"); - } + PseudoClasses.Set(":empty", collection.Count == 0); } /// @@ -367,14 +359,7 @@ namespace Perspex.Controls /// private void SubscribeToItems(IEnumerable items) { - if (items == null || items.Count() == 0) - { - Classes.Add(":empty"); - } - else - { - Classes.Remove(":empty"); - } + PseudoClasses.Set(":empty", items == null || items.Count() == 0); var incc = items as INotifyCollectionChanged; diff --git a/src/Perspex.Controls/Mixins/SelectableMixin.cs b/src/Perspex.Controls/Mixins/SelectableMixin.cs index 75abe9a849..21b04f9702 100644 --- a/src/Perspex.Controls/Mixins/SelectableMixin.cs +++ b/src/Perspex.Controls/Mixins/SelectableMixin.cs @@ -52,7 +52,7 @@ namespace Perspex.Controls.Mixins { if ((bool)x.NewValue) { - sender.Classes.Add(":selected"); + ((IPseudoClasses)sender.Classes).Add(":selected"); if (((IVisual)sender).IsAttachedToVisualTree) { @@ -61,7 +61,7 @@ namespace Perspex.Controls.Mixins } else { - sender.Classes.Remove(":selected"); + ((IPseudoClasses)sender.Classes).Remove(":selected"); } sender.RaiseEvent(new RoutedEventArgs diff --git a/src/Perspex.Controls/Perspex.Controls.csproj b/src/Perspex.Controls/Perspex.Controls.csproj index b554a5a663..b85fe8461b 100644 --- a/src/Perspex.Controls/Perspex.Controls.csproj +++ b/src/Perspex.Controls/Perspex.Controls.csproj @@ -47,6 +47,7 @@ + diff --git a/src/Perspex.Controls/Primitives/SelectingItemsControl.cs b/src/Perspex.Controls/Primitives/SelectingItemsControl.cs index 999766f8af..20181a90fc 100644 --- a/src/Perspex.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Perspex.Controls/Primitives/SelectingItemsControl.cs @@ -546,14 +546,7 @@ namespace Perspex.Controls.Primitives } else { - if (selected) - { - container.Classes.Add(":selected"); - } - else - { - container.Classes.Remove(":selected"); - } + ((IPseudoClasses)container.Classes).Set(":selected", selected); } } finally diff --git a/src/Perspex.Controls/TreeView.cs b/src/Perspex.Controls/TreeView.cs index f479f01238..2dcb136526 100644 --- a/src/Perspex.Controls/TreeView.cs +++ b/src/Perspex.Controls/TreeView.cs @@ -186,14 +186,7 @@ namespace Perspex.Controls } else { - if (selected) - { - container.Classes.Add(":selected"); - } - else - { - container.Classes.Remove(":selected"); - } + ((IPseudoClasses)container.Classes).Set(":selected", selected); } } } diff --git a/tests/Perspex.Controls.UnitTests/ClassesTests.cs b/tests/Perspex.Controls.UnitTests/ClassesTests.cs new file mode 100644 index 0000000000..9669f4ea9c --- /dev/null +++ b/tests/Perspex.Controls.UnitTests/ClassesTests.cs @@ -0,0 +1,153 @@ +// Copyright (c) The Perspex Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using Xunit; + +namespace Perspex.Controls.UnitTests +{ + public class ClassesTests + { + [Fact] + public void Duplicates_Should_Not_Be_Added() + { + var target = new Classes(); + + target.Add("foo"); + target.Add("foo"); + + Assert.Equal(new[] { "foo" }, target); + } + + [Fact] + public void Duplicates_Should_Not_Be_Added_Via_AddRange() + { + var target = new Classes(); + + target.Add("foo"); + target.AddRange(new[] { "foo", "bar" }); + + Assert.Equal(new[] { "foo", "bar" }, target); + } + + [Fact] + public void Duplicates_Should_Not_Be_Added_Via_Pseudoclasses() + { + var target = new Classes(); + var ps = (IPseudoClasses)target; + + ps.Add(":foo"); + ps.Add(":foo"); + + Assert.Equal(new[] { ":foo" }, target); + } + + [Fact] + public void Duplicates_Should_Not_Be_Inserted() + { + var target = new Classes(); + + target.Add("foo"); + target.Insert(0, "foo"); + + Assert.Equal(new[] { "foo" }, target); + } + + [Fact] + public void Duplicates_Should_Not_Be_Inserted_Via_InsertRange() + { + var target = new Classes(); + + target.Add("foo"); + target.InsertRange(1, new[] { "foo", "bar" }); + + Assert.Equal(new[] { "foo", "bar" }, target); + } + + [Fact] + public void Should_Not_Be_Able_To_Add_Pseudoclass() + { + var target = new Classes(); + + Assert.Throws(() => target.Add(":foo")); + } + + [Fact] + public void Should_Not_Be_Able_To_Add_Pseudoclasses_Via_AddRange() + { + var target = new Classes(); + + Assert.Throws(() => target.AddRange(new[] { "foo", ":bar" })); + } + + [Fact] + public void Should_Not_Be_Able_To_Insert_Pseudoclass() + { + var target = new Classes(); + + Assert.Throws(() => target.Insert(0, ":foo")); + } + + [Fact] + public void Should_Not_Be_Able_To_Insert_Pseudoclasses_Via_InsertRange() + { + var target = new Classes(); + + Assert.Throws(() => target.InsertRange(0, new[] { "foo", ":bar" })); + } + + [Fact] + public void Should_Not_Be_Able_To_Remove_Pseudoclass() + { + var target = new Classes(); + + Assert.Throws(() => target.Remove(":foo")); + } + + [Fact] + public void Should_Not_Be_Able_To_Remove_Pseudoclasses_Via_RemoveAll() + { + var target = new Classes(); + + Assert.Throws(() => target.RemoveAll(new[] { "foo", ":bar" })); + } + + [Fact] + public void Should_Not_Be_Able_To_Remove_Pseudoclasses_Via_RemoveRange() + { + var target = new Classes(); + + Assert.Throws(() => target.RemoveRange(0, 1)); + } + + [Fact] + public void Should_Not_Be_Able_To_Remove_Pseudoclass_Via_RemoveAt() + { + var target = new Classes(); + + ((IPseudoClasses)target).Add(":foo"); + + Assert.Throws(() => target.RemoveAt(0)); + } + + [Fact] + public void Replace_Should_Not_Replace_Pseudoclasses() + { + var target = new Classes("foo", "bar"); + + ((IPseudoClasses)target).Add(":baz"); + + target.Replace(new[] { "qux" }); + + Assert.Equal(new[] { ":baz", "qux" }, target); + } + + [Fact] + public void Replace_Should_Not_Accept_Pseudoclasses() + { + var target = new Classes(); + + Assert.Throws(() => target.Replace(new[] { ":qux" })); + } + } +} diff --git a/tests/Perspex.Controls.UnitTests/Perspex.Controls.UnitTests.csproj b/tests/Perspex.Controls.UnitTests/Perspex.Controls.UnitTests.csproj index 49ca24dba4..19078b5b2e 100644 --- a/tests/Perspex.Controls.UnitTests/Perspex.Controls.UnitTests.csproj +++ b/tests/Perspex.Controls.UnitTests/Perspex.Controls.UnitTests.csproj @@ -81,6 +81,7 @@ +