Skip to content

Commit

Permalink
Add bindings (#420)
Browse files Browse the repository at this point in the history
* fix classes and styles properties on StyledElement

- fix classes property on StyledElement.
- move styles property to IStyleHost.fs.

* control catalog: add styles demo back

- add styles demo back
- update styles.xaml for FluentTheme

* fix IStyleHost.styles

- compare list of IStyle correctly
- setter should also update Resources

* add tests for `IStyleHost.styles` and `Control.classes` properties.

* add dataTemplates property.

* add onPropertyChanged event.

* add Net Event Attr functions.

* add Visual DSL functions.

* use `nameof` expression

* add Layoutable DSL functions.

* add InputElement DSL functions.

* add Control DSL functions.

* add Inline DSL functions.

* add TextDecoration DSL functions.

* add TextBlock DSL functions.

* add Image DSL functions.

* move stryles DSL into StyledElement.fs

* add Flyout DSL functions.

* refactor subscription function if passing event source, to use AddHandler/RemoveHandler.

* add TemplatedControl bindings.

* add TextBox bindings.

* add ItemsControl bindings.

* Type parameters Modified to explicitly.

* documentation for updating `Classes`' standard classes

Expanded the documentation for the `patchStandardClasses` function, which updates the standard classes of `Classes`, with detailed explanations about the mixture of standard classes and pseudoclasses.

* fix isPseudoClass

- StartsWith ... use Char instead of String.
- update comment.

* move dataTemplates binding functions to Control.fs

* Remove onTextChanged (TextBox.TextChangingEvent -> unit) binding.

* add test for AttrBuilder<'t>.CreateSubscription<'arg>(name, factory, func, ?subPatchOptions)

* Refactor list / AvaloniaList / IList value bindings

- Add helper function for IList<'t>.
- Use helper function instead of custom Internals functions.
- For list<'t> binding, remove custom compare function.

* fix compare function.
  • Loading branch information
SilkyFowl authored Apr 27, 2024
1 parent a5067bb commit bca10e4
Show file tree
Hide file tree
Showing 21 changed files with 1,082 additions and 69 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,10 @@ module MainView =
TabItem.header "SplitView Demo"
TabItem.content (ViewBuilder.Create<SplitViewDemo.Host>([]))
]
// ToDo: return it back when styles will be worked
//TabItem.create [
// TabItem.header "Styles Demo"
// TabItem.content (ViewBuilder.Create<StylesDemo.Host>([]))
//]
TabItem.create [
TabItem.header "Styles Demo"
TabItem.content (ViewBuilder.Create<StylesDemo.Host>([]))
]
TabItem.create [
TabItem.header "TextBox Demo"
TabItem.content (ViewBuilder.Create<TextBoxDemo.Host>([]))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
<Styles
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="using:System">
<Styles.Resources>
<ResourceDictionary>
<!-- FluentTheme has no common FintSize Resources. -->
<sys:Double x:Key="FontSizeSmall">10</sys:Double>
<sys:Double x:Key="FontSizeNormal">12</sys:Double>
<sys:Double x:Key="FontSizeLarge">16</sys:Double>
</ResourceDictionary>
</Styles.Resources>

<Style Selector="Button.round /template/ ContentPresenter">
<Setter Property="CornerRadius" Value="10"/>
</Style>
Expand All @@ -22,9 +31,9 @@
</Style>

<Style Selector="Border.drag">
<Setter Property="Background" Value="{DynamicResource ThemeAccentBrush}"/>
<Setter Property="Background" Value="{DynamicResource SystemControlHighlightAccentBrush}"/>
</Style>
<Style Selector="Border.drop">
<Setter Property="Background" Value="{DynamicResource ThemeAccentBrush2}"/>
<Setter Property="Background" Value="{DynamicResource SystemControlHighlightAltListAccentMediumBrush}"/>
</Style>
</Styles>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
Expand All @@ -16,6 +16,7 @@
<Compile Include="VirtualDom\VirtualDom.ModuleTests.fs" />
<Compile Include="VirtualDom\VirtualDom.DifferTests.fs" />
<Compile Include="VirtualDom\VirtualDom.PatcherTests.fs" />
<Compile Include="DSL\Base\StyledElementTests.fs" />
<Compile Include="State.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
Expand Down
149 changes: 149 additions & 0 deletions src/Avalonia.FuncUI.UnitTests/DSL/Base/StyledElementTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
namespace Avalonia.FuncUI.UnitTests.DSL

open Avalonia
open Avalonia.Controls
open global.Xunit

module StyledElementTests =
open Avalonia.FuncUI.VirtualDom
open Avalonia.FuncUI.DSL
open Avalonia.FuncUI.Types
open Avalonia.Styling

let twoAttrs<'x, 't> (attr: 'x -> IAttr<'t>) a b =
[ attr a :> IAttr ], [ attr b :> IAttr ]

[<Fact>]
let ``classes equality with string list`` () =
let valueList() = [ "class1"; "class2" ]

let classes1 = valueList()
let classes2 = valueList()

let stringList =
(classes1, classes2)
||> twoAttrs StyledElement.classes
|> Differ.diffAttributes

Assert.Empty stringList

[<Fact>]
let ``classes equality with same classes instance`` () =
let classes = Classes()
classes.Add "class1"
classes.Add "class2"

let sameClassesInstance =
(classes, classes) ||> twoAttrs StyledElement.classes |> Differ.diffAttributes

Assert.Empty sameClassesInstance

[<Fact>]
let ``classes equality with different classes instance`` () =
let classes1 = Classes()
classes1.Add "class1"
classes1.Add "class2"

let classes2 = Classes()
classes2.Add "class1"
classes2.Add "class2"

let differentClassesInstance =
(classes1, classes2) ||> twoAttrs StyledElement.classes |> Differ.diffAttributes

Assert.Empty differentClassesInstance

let initStyle () =
let s = Style(fun x -> x.Is<Control>())
s.Setters.Add(Setter(Control.TagProperty, "foo"))
s :> IStyle

[<Fact>]
let ``styles equality with style list has same style instance`` () =
let style = initStyle ()

let styleList () = [ style ]

let styles1 = styleList ()
let styles2 = styleList ()

let styleList =
(styles1, styles2) ||> twoAttrs StyledElement.styles |> Differ.diffAttributes

Assert.Empty styleList


[<Fact>]
let ``styles equality with style list has different style instance`` () =

let style1 = initStyle ()
let style2 = initStyle ()

let styleList =
([ style1 ], [ style2 ]) ||> twoAttrs StyledElement.styles |> Differ.diffAttributes

match Assert.Single styleList with
| Delta.AttrDelta.Property { Accessor = InstanceProperty { Name = propName }
Value = Some(:? list<IStyle> as [ value ]) } ->
Assert.Equal("Styles", propName)
Assert.NotEqual(style1, value)
Assert.Equal(style2, value)

| _ -> Assert.Fail $"Not expected delta\n{styleList}"

[<Fact>]
let ``styles equality with Styles property has same instance`` () =
let style = initStyle ()

let styles = Styles()
styles.Add style

let styles1 = styles
let styles2 = styles

let styleList =
(styles1, styles2) ||> twoAttrs StyledElement.styles |> Differ.diffAttributes

Assert.Empty styleList

[<Fact>]
let ``styles equality with Styles property has different Styles instance has same style instance`` () =
let style = initStyle ()

let styles1 = Styles()
styles1.Add style
styles1.Resources.Add("key", "value")

let styles2 = Styles()
styles2.Add style
styles2.Resources.Add("key", "value")

let styleList =
(styles1, styles2) ||> twoAttrs StyledElement.styles |> Differ.diffAttributes

Assert.Empty styleList

[<Fact>]
let ``styles equality with Styles property has different Styles instance has different style instance`` () =
let style1 = initStyle ()
let style2 = initStyle ()

let styles1 = Styles()
styles1.Add style1
styles1.Resources.Add("key", "value")

let styles2 = Styles()
styles2.Add style2
styles2.Resources.Add("key", "value")

let styleList =
(styles1, styles2) ||> twoAttrs StyledElement.styles |> Differ.diffAttributes

match Assert.Single styleList with
| Delta.AttrDelta.Property { Accessor = InstanceProperty { Name = propName }
Value = Some(:? Styles as value) } ->
Assert.Equal("Styles", propName)
Assert.NotEqual<Styles>(styles1, value)
Assert.Equal<Styles>(styles2, value)

| _ -> Assert.Fail $"Not expected delta\n{styleList}"
120 changes: 117 additions & 3 deletions src/Avalonia.FuncUI.UnitTests/VirtualDom/VirtualDom.PatcherTests.fs
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
namespace Avalonia.FuncUI.UnitTests.VirtualDom

open System
open System.Threading
open System.Collections.Generic
open Avalonia
open Avalonia.Controls
open Avalonia.Media
open Avalonia.Styling

module PatcherTests =
open Avalonia.FuncUI.VirtualDom
open Avalonia.FuncUI.Builder
open Avalonia.FuncUI.DSL
open Avalonia.FuncUI.Types
open Avalonia.Controls
open Avalonia.FuncUI.VirtualDom
open Xunit
open Avalonia.Media

[<Fact>]
let ``Patch Properties`` () =
Expand Down Expand Up @@ -248,3 +251,114 @@ module PatcherTests =
Assert.IsType(typeof<Button>, stackpanel.Children.[2])
let button = stackpanel.Children.[2] :?> Button
Assert.Equal(SolidColorBrush.Parse("green").ToImmutable() :> IBrush, button.Background)

[<Fact>]
let ``Patch Custom Subscription`` () =
/// Capture list for factory called.
let factoryCaptures = ResizeArray()
/// Capture list for token cancellation called.
let tokenCancelledCaptures = ResizeArray()

/// Custom subscription binding function for testing common pattern of subscribing to .NET Event in FuncUI.
let onTextChanging (func, subPatchOptions) =
let name = "Test_TextChanged"

/// Custom subscription factory for `TextBox.TextChanging`.
let factory: AvaloniaObject * ('t * TextChangingEventArgs -> unit) * CancellationToken -> unit when 't :> TextBox =
fun (control, func, token) ->
// When factory is called, capture the subPatchOptions.
factoryCaptures.Add subPatchOptions

let control = control :?> 't
let handler = EventHandler<TextChangingEventArgs>(fun s e -> func(s :?> 't, e))
let event = control.TextChanging
event.AddHandler(handler)

// Register unsubscribe action to token.
token.Register(fun _ ->
// When token.Cancel is called, capture the subPatchOptions.
tokenCancelledCaptures.Add subPatchOptions
event.RemoveHandler(handler)
) |> ignore

AttrBuilder<'t>.CreateSubscription<'t * TextChangingEventArgs>(name, factory, func, subPatchOptions)

/// Capture list for text changing event.
let textChangingCaptures = ResizeArray()

/// testing view function for TextBox.
///
/// Control is updated by `IAttr<'t> list` in order. This test is to confirm the behavior of
/// event subscription around this specification.
let view text subPatchStr =
TextBox.create [
TextBox.text text
onTextChanging (
(fun (tb, e) -> textChangingCaptures.Add $"{subPatchStr}-{tb.Text}"),
OnChangeOf subPatchStr
)
]

/// initial view definition. Only set text value.
let initView = TextBox.create [ TextBox.text "Foo" ]
/// 1st update view definition. Add event subscription.
let updatedView = view "Foo" "FirstSubPatch"
/// 2nd update view definition. Only update text value.
let updatedView' = view "Hoge" "FirstSubPatch"
/// 3rd update view definition. Update text value and subscription subPatch.
let updatedView'' = view "Bar" "SecondSubPatch"
/// 4th update view definition. Only update text value.
let updatedView''' = view "Fuga" "SecondSubPatch"

/// create target control.
let target = VirtualDom.create initView :?> TextBox

// 1st update.
VirtualDom.update(target, initView, updatedView)
// Check text value.
Assert.Equal("Foo", target.Text)
// Check event subscription.
Assert.Equal(1, factoryCaptures.Count)
Assert.Equal(OnChangeOf "FirstSubPatch", factoryCaptures[0])
// No token cancellation.
Assert.Empty tokenCancelledCaptures
// When text has not changed, event is not fired.
Assert.Empty textChangingCaptures

// 2nd update.
VirtualDom.update(target, updatedView, updatedView')
// Text value is updated.
Assert.Equal("Hoge", target.Text)
// Subscription is not updated.
Assert.Equal(1, factoryCaptures.Count)
// No token cancellation.
Assert.Empty tokenCancelledCaptures
// Check event fired.
Assert.Equal(1, textChangingCaptures.Count)
Assert.Equal("FirstSubPatch-Hoge", textChangingCaptures[0])

// 3rd update.
VirtualDom.update(target, updatedView', updatedView'')
// Text value is updated.
Assert.Equal("Bar", target.Text)
// Subscription is updated.
Assert.Equal(2, factoryCaptures.Count)
Assert.Equal(OnChangeOf "SecondSubPatch", factoryCaptures.[1])
// Old callback is unsubscribed.
Assert.Equal(1, tokenCancelledCaptures.Count)
Assert.Equal(OnChangeOf "FirstSubPatch", tokenCancelledCaptures.[0])
// Check event fired. Text value update faster than subscription update, so old callback is called.
Assert.Equal(2, textChangingCaptures.Count)
Assert.Equal("FirstSubPatch-Bar", textChangingCaptures.[1])

// 4th update.
VirtualDom.update(target, updatedView'', updatedView''')
// Text value is updated.
Assert.Equal("Fuga", target.Text)
// Subscription is not updated.
Assert.Equal(2, factoryCaptures.Count)
// subscription is not cancelled.
Assert.Equal(1, tokenCancelledCaptures.Count)
// Check event fired. New callback is called.
Assert.Equal(3, textChangingCaptures.Count)
Assert.Equal("SecondSubPatch-Fuga", textChangingCaptures.[2])
4 changes: 3 additions & 1 deletion src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
Expand Down Expand Up @@ -97,6 +97,8 @@
<Compile Include="DSL\Shapes\Path.fs" />
<Compile Include="DSL\Calendar\Calendar.fs" />
<Compile Include="DSL\Calendar\CalendarDatePicker.fs" />
<Compile Include="DSL\Documents\TextDecoration.fs" />
<Compile Include="DSL\Documents\Inline.fs" />
<Compile Include="DSL\Documents\Run.fs" />
<Compile Include="DSL\Documents\Span.fs" />
<Compile Include="DSL\Documents\Bold.fs" />
Expand Down
14 changes: 14 additions & 0 deletions src/Avalonia.FuncUI/DSL/Base/AvaloniaObject.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ namespace Avalonia.FuncUI.DSL
open Avalonia
open Avalonia.FuncUI
open Avalonia.FuncUI.Types
open System.Threading

[<AutoOpen>]
module AvaloniaObject =
open Avalonia.FuncUI.Types
open Avalonia.FuncUI.Builder

type AvaloniaObject with

Expand All @@ -31,6 +34,17 @@ module AvaloniaObject =
InitFunction.Function = (fun (control: obj) -> func (control :?> 't))
}

static member onPropertyChanged<'t when 't :> AvaloniaObject>(func: AvaloniaPropertyChangedEventArgs -> unit, ?subPatchOptions) : IAttr<'t> =
let name = nameof Unchecked.defaultof<'t>.PropertyChanged
let factory: AvaloniaObject * (AvaloniaPropertyChangedEventArgs -> unit) * CancellationToken -> unit =
(fun (control, func, token) ->
let control = control :?> 't
let disposable = control.PropertyChanged.Subscribe(func)

token.Register(fun () -> disposable.Dispose()) |> ignore)

AttrBuilder<'t>.CreateSubscription<AvaloniaPropertyChangedEventArgs>(name, factory, func, ?subPatchOptions = subPatchOptions)

member this.Bind(prop: DirectPropertyBase<'value>, readable: #IReadable<'value>) : unit =
let _ = this.Bind(property = prop, source = readable.ImmediateObservable)

Check warning on line 49 in src/Avalonia.FuncUI/DSL/Base/AvaloniaObject.fs

View workflow job for this annotation

GitHub Actions / build

Same as Observable, but fires once immediately after subscribing. This warning can be disabled using '--nowarn:57' or '#nowarn "57"'.

Check warning on line 49 in src/Avalonia.FuncUI/DSL/Base/AvaloniaObject.fs

View workflow job for this annotation

GitHub Actions / build

Same as Observable, but fires once immediately after subscribing. This warning can be disabled using '--nowarn:57' or '#nowarn "57"'.

Check warning on line 49 in src/Avalonia.FuncUI/DSL/Base/AvaloniaObject.fs

View workflow job for this annotation

GitHub Actions / build

Same as Observable, but fires once immediately after subscribing. This warning can be disabled using '--nowarn:57' or '#nowarn "57"'.
()
Expand Down
Loading

0 comments on commit bca10e4

Please sign in to comment.