From 9dcdfa7b26f00925bb58c2fc1dba13ea5982a065 Mon Sep 17 00:00:00 2001 From: Josua Jaeger Date: Fri, 12 Apr 2024 16:42:55 +0200 Subject: [PATCH 1/7] Current situation: If the render function of a component closes over/captures state that state is stale. This is the case because we never change the render function - even tho it's instantiated. This change makes it possible to access outer state and re-render when captured state changes. This is still a prototype. --- src/Avalonia.FuncUI.sln | 7 ++ src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj | 1 + src/Avalonia.FuncUI/Components/Component.fs | 35 +++++--- src/Avalonia.FuncUI/DSL/Component.fs | 37 +++++++++ .../Examples.ComponentPlayground.fsproj | 24 ++++++ .../Examples.ComponentPlayground/Program.fs | 80 +++++++++++++++++++ 6 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 src/Avalonia.FuncUI/DSL/Component.fs create mode 100644 src/Examples/Examples.ComponentPlayground/Examples.ComponentPlayground.fsproj create mode 100644 src/Examples/Examples.ComponentPlayground/Program.fs diff --git a/src/Avalonia.FuncUI.sln b/src/Avalonia.FuncUI.sln index bba84e6e..d498ba85 100644 --- a/src/Avalonia.FuncUI.sln +++ b/src/Avalonia.FuncUI.sln @@ -88,6 +88,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Elmish Examples", "Elmish E EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Examples.Elmish.Tetris", "Examples\Elmish Examples\Examples.Elmish.Tetris\Examples.Elmish.Tetris.fsproj", "{EC63B886-E809-4B74-B533-BFF3D60017C9}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Examples.ComponentPlayground", "Examples\Examples.ComponentPlayground\Examples.ComponentPlayground.fsproj", "{FA4E81D2-1083-43E1-99E3-0F4EA31B3701}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -206,6 +208,10 @@ Global {EC63B886-E809-4B74-B533-BFF3D60017C9}.Debug|Any CPU.Build.0 = Debug|Any CPU {EC63B886-E809-4B74-B533-BFF3D60017C9}.Release|Any CPU.ActiveCfg = Release|Any CPU {EC63B886-E809-4B74-B533-BFF3D60017C9}.Release|Any CPU.Build.0 = Release|Any CPU + {FA4E81D2-1083-43E1-99E3-0F4EA31B3701}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA4E81D2-1083-43E1-99E3-0F4EA31B3701}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA4E81D2-1083-43E1-99E3-0F4EA31B3701}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA4E81D2-1083-43E1-99E3-0F4EA31B3701}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -242,6 +248,7 @@ Global {70BDCE72-149A-435A-9910-E79DE329F978} = {F50826CE-D9BC-45CF-A110-C42225B75AD3} {6D2C62FC-5634-4997-AF1F-2E8A5D27E117} = {84811DB3-C276-4F0D-B3BA-78B88E2C6EF0} {EC63B886-E809-4B74-B533-BFF3D60017C9} = {6D2C62FC-5634-4997-AF1F-2E8A5D27E117} + {FA4E81D2-1083-43E1-99E3-0F4EA31B3701} = {F50826CE-D9BC-45CF-A110-C42225B75AD3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4630E817-6780-4C98-9379-EA3B45224339} diff --git a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj index 64cdb065..6a9c115e 100644 --- a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj +++ b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj @@ -56,6 +56,7 @@ + diff --git a/src/Avalonia.FuncUI/Components/Component.fs b/src/Avalonia.FuncUI/Components/Component.fs index ad313375..6caf1a37 100644 --- a/src/Avalonia.FuncUI/Components/Component.fs +++ b/src/Avalonia.FuncUI/Components/Component.fs @@ -1,7 +1,9 @@ namespace Avalonia.FuncUI open System +open System.ComponentModel open System.Diagnostics.CodeAnalysis +open Avalonia open Avalonia.Controls open Avalonia.FuncUI open Avalonia.FuncUI.Types @@ -13,15 +15,28 @@ open Avalonia.Threading type Component (render: IComponentContext -> IView) as this = inherit ComponentBase () - override this.Render ctx = - render ctx + static let RenderFunctionProperty = + AvaloniaProperty.RegisterDirect IView>( + name = "RenderFunction", + getter = Func IView>(_.RenderFunction), + setter = (fun this value -> this.RenderFunction <- value) + ) + + static do + let _ = RenderFunctionProperty.Changed.AddClassHandler IView>(fun this e -> + this.ForceRender() + + ) + () -type Component with + let mutable _renderFunction: IComponentContext -> IView = render - static member create(key: string, render: IComponentContext -> IView) : IView = - { View.ViewType = typeof - View.ViewKey = ValueSome key - View.Attrs = list.Empty - View.Outlet = ValueNone - View.ConstructorArgs = [| render :> obj |] } - :> IView \ No newline at end of file + member this.RenderFunction + with get() = _renderFunction + and set(value) = + let didChange = this.SetAndRaise(RenderFunctionProperty, ref _renderFunction, value) + _renderFunction <- value + () + + override this.Render ctx = + _renderFunction ctx \ No newline at end of file diff --git a/src/Avalonia.FuncUI/DSL/Component.fs b/src/Avalonia.FuncUI/DSL/Component.fs new file mode 100644 index 00000000..fa3d95ec --- /dev/null +++ b/src/Avalonia.FuncUI/DSL/Component.fs @@ -0,0 +1,37 @@ +[] +module Avalonia.FuncUI.DSL.__ComponentExtensions + +open Avalonia.FuncUI +open Avalonia.FuncUI.Builder +open Avalonia.FuncUI.Types + +type Component with + + static member internal renderFunction<'t when 't :> Component>(value: IComponentContext -> IView) : IAttr<'t> = + AttrBuilder<'t>.CreateProperty IView>( + "RenderFunction", + value, + ValueSome (fun (view: 't) -> view.RenderFunction), + ValueSome (fun (view: 't, value) -> view.RenderFunction <- value), + ValueNone + ) + + static member create(key: string, render: IComponentContext -> IView) : IView = + { View.ViewType = typeof + View.ViewKey = ValueSome key + View.Attrs = [ + Component.renderFunction render + ] + View.Outlet = ValueNone + View.ConstructorArgs = [| render :> obj |] } + :> IView + + static member create(render: IComponentContext -> IView) : IView = + { View.ViewType = typeof + View.ViewKey = ValueNone + View.Attrs = [ + Component.renderFunction render + ] + View.Outlet = ValueNone + View.ConstructorArgs = [| render :> obj |] } + :> IView \ No newline at end of file diff --git a/src/Examples/Examples.ComponentPlayground/Examples.ComponentPlayground.fsproj b/src/Examples/Examples.ComponentPlayground/Examples.ComponentPlayground.fsproj new file mode 100644 index 00000000..0f3d1a0a --- /dev/null +++ b/src/Examples/Examples.ComponentPlayground/Examples.ComponentPlayground.fsproj @@ -0,0 +1,24 @@ + + + + Exe + net8.0 + + + + + + + + + + + + + + + + + + + diff --git a/src/Examples/Examples.ComponentPlayground/Program.fs b/src/Examples/Examples.ComponentPlayground/Program.fs new file mode 100644 index 00000000..60f5370b --- /dev/null +++ b/src/Examples/Examples.ComponentPlayground/Program.fs @@ -0,0 +1,80 @@ +namespace Examples.CounterApp + +open Avalonia +open Avalonia.Controls.ApplicationLifetimes +open Avalonia.Themes.Fluent +open Avalonia.FuncUI.Hosts +open Avalonia.Controls + +module Main = + open Avalonia.Controls + open Avalonia.FuncUI + open Avalonia.FuncUI.DSL + open Avalonia.Layout + + let counterView (count: int) = + Component.create (fun ctx -> + TextBlock.create [ + TextBlock.dock Dock.Top + TextBlock.fontSize 48.0 + TextBlock.verticalAlignment VerticalAlignment.Center + TextBlock.horizontalAlignment HorizontalAlignment.Center + TextBlock.text (string count) + ] + ) + + let view () = + Component (fun ctx -> + let state = ctx.useState 0 + + DockPanel.create [ + DockPanel.children [ + Button.create [ + Button.dock Dock.Bottom + Button.onClick (fun _ -> state.Current - 1 |> state.Set) + Button.content "-" + Button.horizontalAlignment HorizontalAlignment.Stretch + ] + Button.create [ + Button.dock Dock.Bottom + Button.onClick (fun _ -> state.Current + 1 |> state.Set) + Button.content "+" + Button.horizontalAlignment HorizontalAlignment.Stretch + ] + counterView state.Current + ] + ] + ) + + +type MainWindow() = + inherit HostWindow() + do + base.Title <- "Counter Example" + base.Height <- 400.0 + base.Width <- 400.0 + base.Content <- Main.view () + +type App() = + inherit Application() + + override this.Initialize() = + this.Styles.Add (FluentTheme()) + this.RequestedThemeVariant <- Styling.ThemeVariant.Dark + + override this.OnFrameworkInitializationCompleted() = + match this.ApplicationLifetime with + | :? IClassicDesktopStyleApplicationLifetime as desktopLifetime -> + let mainWindow = MainWindow() + desktopLifetime.MainWindow <- mainWindow + | _ -> () + +module Program = + + [] + let main(args: string[]) = + AppBuilder + .Configure() + .UsePlatformDetect() + .UseSkia() + .StartWithClassicDesktopLifetime(args) From 4e8d91d5eb95c3ab07de7d7d6bdf9191d7a9a1b5 Mon Sep 17 00:00:00 2001 From: Josua Jaeger Date: Sun, 14 Apr 2024 14:47:00 +0200 Subject: [PATCH 2/7] - don't use ref cells, but actually pass the field by reference. This requires not using an FSharp function as getting the address of a function type is prevented by the compiler. I guess casting the function to an object might have also worked here. - simplify bindings by passing in the RenderFunctionProperty --- src/Avalonia.FuncUI/Components/Component.fs | 16 ++++++++-------- src/Avalonia.FuncUI/DSL/Component.fs | 13 +++---------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/Avalonia.FuncUI/Components/Component.fs b/src/Avalonia.FuncUI/Components/Component.fs index 6caf1a37..3d9f3a03 100644 --- a/src/Avalonia.FuncUI/Components/Component.fs +++ b/src/Avalonia.FuncUI/Components/Component.fs @@ -15,28 +15,28 @@ open Avalonia.Threading type Component (render: IComponentContext -> IView) as this = inherit ComponentBase () - static let RenderFunctionProperty = - AvaloniaProperty.RegisterDirect IView>( + static let _RenderFunctionProperty = + AvaloniaProperty.RegisterDirect>( name = "RenderFunction", - getter = Func IView>(_.RenderFunction), + getter = Func>(_.RenderFunction), setter = (fun this value -> this.RenderFunction <- value) ) static do - let _ = RenderFunctionProperty.Changed.AddClassHandler IView>(fun this e -> + let _ = _RenderFunctionProperty.Changed.AddClassHandler>(fun this e -> this.ForceRender() ) () + let mutable _renderFunction: Func = render - let mutable _renderFunction: IComponentContext -> IView = render + static member RenderFunctionProperty = _RenderFunctionProperty member this.RenderFunction with get() = _renderFunction and set(value) = - let didChange = this.SetAndRaise(RenderFunctionProperty, ref _renderFunction, value) - _renderFunction <- value + let didChange = this.SetAndRaise(Component.RenderFunctionProperty, &_renderFunction, value) () override this.Render ctx = - _renderFunction ctx \ No newline at end of file + _renderFunction.Invoke ctx \ No newline at end of file diff --git a/src/Avalonia.FuncUI/DSL/Component.fs b/src/Avalonia.FuncUI/DSL/Component.fs index fa3d95ec..e12f1c9f 100644 --- a/src/Avalonia.FuncUI/DSL/Component.fs +++ b/src/Avalonia.FuncUI/DSL/Component.fs @@ -1,6 +1,7 @@ [] module Avalonia.FuncUI.DSL.__ComponentExtensions +open System open Avalonia.FuncUI open Avalonia.FuncUI.Builder open Avalonia.FuncUI.Types @@ -8,20 +9,12 @@ open Avalonia.FuncUI.Types type Component with static member internal renderFunction<'t when 't :> Component>(value: IComponentContext -> IView) : IAttr<'t> = - AttrBuilder<'t>.CreateProperty IView>( - "RenderFunction", - value, - ValueSome (fun (view: 't) -> view.RenderFunction), - ValueSome (fun (view: 't, value) -> view.RenderFunction <- value), - ValueNone - ) + AttrBuilder<'t>.CreateProperty>(Component.RenderFunctionProperty, value, ValueNone) static member create(key: string, render: IComponentContext -> IView) : IView = { View.ViewType = typeof View.ViewKey = ValueSome key - View.Attrs = [ - Component.renderFunction render - ] + View.Attrs = List.Empty View.Outlet = ValueNone View.ConstructorArgs = [| render :> obj |] } :> IView From e9c5035b4f41562c0e72840f96ca7b8d94a43123 Mon Sep 17 00:00:00 2001 From: Josua Jaeger Date: Sun, 14 Apr 2024 14:52:57 +0200 Subject: [PATCH 3/7] - patch render function for all DSL created component types. --- src/Avalonia.FuncUI/DSL/Component.fs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.FuncUI/DSL/Component.fs b/src/Avalonia.FuncUI/DSL/Component.fs index e12f1c9f..53bb00f8 100644 --- a/src/Avalonia.FuncUI/DSL/Component.fs +++ b/src/Avalonia.FuncUI/DSL/Component.fs @@ -14,7 +14,9 @@ type Component with static member create(key: string, render: IComponentContext -> IView) : IView = { View.ViewType = typeof View.ViewKey = ValueSome key - View.Attrs = List.Empty + View.Attrs = [ + Component.renderFunction render + ] View.Outlet = ValueNone View.ConstructorArgs = [| render :> obj |] } :> IView From 42319706d3033db6b6ee57c532eb6f4070c6c927 Mon Sep 17 00:00:00 2001 From: Josua Jaeger Date: Sun, 14 Apr 2024 15:44:41 +0200 Subject: [PATCH 4/7] first shot at only re-rendering components that have capturing render functions. This works but is still a bit messy. --- src/Avalonia.FuncUI/Components/Component.fs | 20 ++++++---- .../Components/Lib/Lib.Common.fs | 30 ++++++++++++++ src/Avalonia.FuncUI/DSL/Component.fs | 2 +- src/Avalonia.FuncUI/Library.fs | 6 +-- .../Examples.ComponentPlayground/Program.fs | 40 ++++++++++++++++--- 5 files changed, 81 insertions(+), 17 deletions(-) diff --git a/src/Avalonia.FuncUI/Components/Component.fs b/src/Avalonia.FuncUI/Components/Component.fs index 3d9f3a03..4171610f 100644 --- a/src/Avalonia.FuncUI/Components/Component.fs +++ b/src/Avalonia.FuncUI/Components/Component.fs @@ -6,6 +6,7 @@ open System.Diagnostics.CodeAnalysis open Avalonia open Avalonia.Controls open Avalonia.FuncUI +open Avalonia.FuncUI.Library open Avalonia.FuncUI.Types open Avalonia.FuncUI.VirtualDom open Avalonia.Threading @@ -16,27 +17,32 @@ type Component (render: IComponentContext -> IView) as this = inherit ComponentBase () static let _RenderFunctionProperty = - AvaloniaProperty.RegisterDirect>( + AvaloniaProperty.RegisterDirect IView>( name = "RenderFunction", - getter = Func>(_.RenderFunction), + getter = Func IView>(_.RenderFunction), setter = (fun this value -> this.RenderFunction <- value) ) static do - let _ = _RenderFunctionProperty.Changed.AddClassHandler>(fun this e -> - this.ForceRender() + let _ = _RenderFunctionProperty.Changed.AddClassHandler IView>(fun this e -> + let capturesState = RenderFunctionAnalysis.capturesState(e.NewValue.Value) + if capturesState then + this.ForceRender() ) () - let mutable _renderFunction: Func = render + + let mutable _renderFunction = render static member RenderFunctionProperty = _RenderFunctionProperty member this.RenderFunction with get() = _renderFunction and set(value) = - let didChange = this.SetAndRaise(Component.RenderFunctionProperty, &_renderFunction, value) + let oldValue = _renderFunction + _renderFunction <- value + let _ = this.RaisePropertyChanged(Component.RenderFunctionProperty, oldValue, value) () override this.Render ctx = - _renderFunction.Invoke ctx \ No newline at end of file + _renderFunction ctx \ No newline at end of file diff --git a/src/Avalonia.FuncUI/Components/Lib/Lib.Common.fs b/src/Avalonia.FuncUI/Components/Lib/Lib.Common.fs index 9af5016a..20ca6e3d 100644 --- a/src/Avalonia.FuncUI/Components/Lib/Lib.Common.fs +++ b/src/Avalonia.FuncUI/Components/Lib/Lib.Common.fs @@ -2,6 +2,7 @@ namespace Avalonia.FuncUI open System open System.Collections.Generic +open Microsoft.FSharp.Core [] module internal ComponentHelpers = @@ -38,3 +39,32 @@ module internal CommonExtensions = type Guid with member this.StringValue with get () = this.ToString() static member Unique with get () = Guid.NewGuid() + + +[] +module internal RenderFunctionAnalysis = + open System + open System.Reflection + open System.Collections.Concurrent + + let internal cache = ConcurrentDictionary() + + let private flags = + BindingFlags.Instance ||| + BindingFlags.NonPublic ||| + BindingFlags.Public + + let capturesState (func : obj) : bool = + let type' = func.GetType() + + let hasValue, value = cache.TryGetValue type' + + match hasValue with + | true -> value + | false -> + let capturesState = + type'.GetConstructors(flags) + |> Array.map (fun info -> info.GetParameters().Length) + |> Array.exists (fun parameterLength -> parameterLength > 0) + + cache.AddOrUpdate(type', capturesState, (fun identifier lastValue -> capturesState)) \ No newline at end of file diff --git a/src/Avalonia.FuncUI/DSL/Component.fs b/src/Avalonia.FuncUI/DSL/Component.fs index 53bb00f8..a177f587 100644 --- a/src/Avalonia.FuncUI/DSL/Component.fs +++ b/src/Avalonia.FuncUI/DSL/Component.fs @@ -9,7 +9,7 @@ open Avalonia.FuncUI.Types type Component with static member internal renderFunction<'t when 't :> Component>(value: IComponentContext -> IView) : IAttr<'t> = - AttrBuilder<'t>.CreateProperty>(Component.RenderFunctionProperty, value, ValueNone) + AttrBuilder<'t>.CreateProperty IView>(Component.RenderFunctionProperty, value, ValueNone) static member create(key: string, render: IComponentContext -> IView) : IView = { View.ViewType = typeof diff --git a/src/Avalonia.FuncUI/Library.fs b/src/Avalonia.FuncUI/Library.fs index a5d75a1e..221f78fc 100644 --- a/src/Avalonia.FuncUI/Library.fs +++ b/src/Avalonia.FuncUI/Library.fs @@ -44,10 +44,10 @@ module internal Extensions = let handler = EventHandler<'args>(fun _ e -> observer.OnNext e ) - + // subscribe to event changes so they can be pushed to subscribers this.AddDisposableHandler(routedEvent, handler, routedEvent.RoutingStrategies) ) - + { new IObservable<'args> - with member this.Subscribe(observer: IObserver<'args>) = sub.Invoke(observer) } \ No newline at end of file + with member this.Subscribe(observer: IObserver<'args>) = sub.Invoke(observer) } diff --git a/src/Examples/Examples.ComponentPlayground/Program.fs b/src/Examples/Examples.ComponentPlayground/Program.fs index 60f5370b..dfc8f0cc 100644 --- a/src/Examples/Examples.ComponentPlayground/Program.fs +++ b/src/Examples/Examples.ComponentPlayground/Program.fs @@ -1,25 +1,53 @@ namespace Examples.CounterApp - +open System open Avalonia open Avalonia.Controls.ApplicationLifetimes +open Avalonia.Media open Avalonia.Themes.Fluent open Avalonia.FuncUI.Hosts open Avalonia.Controls +module Helpers = + + let randomColor () : string = + String.Format("#{0:X6}", Random.Shared.Next(0x1000000)); + module Main = open Avalonia.Controls open Avalonia.FuncUI open Avalonia.FuncUI.DSL open Avalonia.Layout - let counterView (count: int) = - Component.create (fun ctx -> + let counterViewNegativeIndicator () = + Component.create (fun _ -> TextBlock.create [ - TextBlock.dock Dock.Top - TextBlock.fontSize 48.0 + TextBlock.fontSize 24.0 TextBlock.verticalAlignment VerticalAlignment.Center TextBlock.horizontalAlignment HorizontalAlignment.Center - TextBlock.text (string count) + TextBlock.background (SolidColorBrush.Parse (Helpers.randomColor())) + TextBlock.text "Negative" + ] + ) + + let counterView (count: int) = + Component.create (fun _ -> + StackPanel.create [ + StackPanel.dock Dock.Top + StackPanel.spacing 5 + StackPanel.orientation Orientation.Horizontal + StackPanel.horizontalAlignment HorizontalAlignment.Center + StackPanel.children [ + + TextBlock.create [ + TextBlock.fontSize 48.0 + TextBlock.verticalAlignment VerticalAlignment.Center + TextBlock.horizontalAlignment HorizontalAlignment.Center + TextBlock.text (string count) + ] + + if count < 0 then + counterViewNegativeIndicator () + ] ] ) From 755dec8f88645dd5aaf53841f5a37f2bf9949f3f Mon Sep 17 00:00:00 2001 From: Josua Jaeger Date: Sun, 14 Apr 2024 22:02:46 +0200 Subject: [PATCH 5/7] split component into two classes: Component: plain old component, unchanged from master ClosureComponent: component that works well with render function that captures state --- src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj | 1 + .../Components/ClosureComponent.fs | 44 +++++++++++++++++ src/Avalonia.FuncUI/Components/Component.fs | 37 +-------------- src/Avalonia.FuncUI/DSL/Component.fs | 47 ++++++++++++------- .../Examples.ComponentPlayground/Program.fs | 17 ++++++- 5 files changed, 92 insertions(+), 54 deletions(-) create mode 100644 src/Avalonia.FuncUI/Components/ClosureComponent.fs diff --git a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj index 6a9c115e..307eaaf5 100644 --- a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj +++ b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj @@ -51,6 +51,7 @@ + diff --git a/src/Avalonia.FuncUI/Components/ClosureComponent.fs b/src/Avalonia.FuncUI/Components/ClosureComponent.fs new file mode 100644 index 00000000..d9b35a4d --- /dev/null +++ b/src/Avalonia.FuncUI/Components/ClosureComponent.fs @@ -0,0 +1,44 @@ +namespace Avalonia.FuncUI + +open System +open System.Diagnostics.CodeAnalysis +open Avalonia +open Avalonia.FuncUI +open Avalonia.FuncUI.Types + +/// Component that works well with a render function that captures state. +[] +[] +type ClosureComponent (render: IComponentContext -> IView) as this = + inherit ComponentBase () + + static let _RenderFunctionProperty = + AvaloniaProperty.RegisterDirect IView>( + name = "RenderFunction", + getter = Func IView>(_.RenderFunction), + setter = (fun this value -> this.RenderFunction <- value) + ) + + static do + let _ = _RenderFunctionProperty.Changed.AddClassHandler IView>(fun this e -> + let capturesState = RenderFunctionAnalysis.capturesState(e.NewValue.Value) + + if capturesState then + this.ForceRender() + ) + () + + let mutable _renderFunction = render + + static member RenderFunctionProperty = _RenderFunctionProperty + + member this.RenderFunction + with get() = _renderFunction + and set(value) = + let oldValue = _renderFunction + _renderFunction <- value + let _ = this.RaisePropertyChanged(ClosureComponent.RenderFunctionProperty, oldValue, value) + () + + override this.Render ctx = + _renderFunction ctx \ No newline at end of file diff --git a/src/Avalonia.FuncUI/Components/Component.fs b/src/Avalonia.FuncUI/Components/Component.fs index 4171610f..035db30f 100644 --- a/src/Avalonia.FuncUI/Components/Component.fs +++ b/src/Avalonia.FuncUI/Components/Component.fs @@ -1,48 +1,13 @@ namespace Avalonia.FuncUI -open System -open System.ComponentModel open System.Diagnostics.CodeAnalysis -open Avalonia -open Avalonia.Controls open Avalonia.FuncUI -open Avalonia.FuncUI.Library open Avalonia.FuncUI.Types -open Avalonia.FuncUI.VirtualDom -open Avalonia.Threading [] [] type Component (render: IComponentContext -> IView) as this = inherit ComponentBase () - static let _RenderFunctionProperty = - AvaloniaProperty.RegisterDirect IView>( - name = "RenderFunction", - getter = Func IView>(_.RenderFunction), - setter = (fun this value -> this.RenderFunction <- value) - ) - - static do - let _ = _RenderFunctionProperty.Changed.AddClassHandler IView>(fun this e -> - let capturesState = RenderFunctionAnalysis.capturesState(e.NewValue.Value) - - if capturesState then - this.ForceRender() - ) - () - - let mutable _renderFunction = render - - static member RenderFunctionProperty = _RenderFunctionProperty - - member this.RenderFunction - with get() = _renderFunction - and set(value) = - let oldValue = _renderFunction - _renderFunction <- value - let _ = this.RaisePropertyChanged(Component.RenderFunctionProperty, oldValue, value) - () - override this.Render ctx = - _renderFunction ctx \ No newline at end of file + render ctx \ No newline at end of file diff --git a/src/Avalonia.FuncUI/DSL/Component.fs b/src/Avalonia.FuncUI/DSL/Component.fs index a177f587..5661661e 100644 --- a/src/Avalonia.FuncUI/DSL/Component.fs +++ b/src/Avalonia.FuncUI/DSL/Component.fs @@ -1,32 +1,47 @@ [] module Avalonia.FuncUI.DSL.__ComponentExtensions -open System open Avalonia.FuncUI open Avalonia.FuncUI.Builder open Avalonia.FuncUI.Types type Component with - static member internal renderFunction<'t when 't :> Component>(value: IComponentContext -> IView) : IAttr<'t> = - AttrBuilder<'t>.CreateProperty IView>(Component.RenderFunctionProperty, value, ValueNone) - - static member create(key: string, render: IComponentContext -> IView) : IView = + static member create(key: string, render: IComponentContext -> IView) : IView = { View.ViewType = typeof View.ViewKey = ValueSome key View.Attrs = [ - Component.renderFunction render + //Component.renderFunction render ] View.Outlet = ValueNone View.ConstructorArgs = [| render :> obj |] } - :> IView + :> IView - static member create(render: IComponentContext -> IView) : IView = - { View.ViewType = typeof - View.ViewKey = ValueNone - View.Attrs = [ - Component.renderFunction render - ] - View.Outlet = ValueNone - View.ConstructorArgs = [| render :> obj |] } - :> IView \ No newline at end of file +type ClosureComponent with + + static member internal renderFunction<'t when 't :> ClosureComponent>(value: IComponentContext -> IView) : IAttr<'t> = + AttrBuilder<'t>.CreateProperty IView>(ClosureComponent.RenderFunctionProperty, value, ValueNone) + + static member create(key: string, render: IComponentContext -> IView) : IView = + let view: View = + { View.ViewType = typeof + View.ViewKey = ValueSome key + View.Attrs = [ + ClosureComponent.renderFunction render + ] + View.Outlet = ValueNone + View.ConstructorArgs = [| render :> obj |] } + + view :> IView + + static member create(render: IComponentContext -> IView) : IView = + let view: View = + { View.ViewType = typeof + View.ViewKey = ValueNone + View.Attrs = [ + ClosureComponent.renderFunction render + ] + View.Outlet = ValueNone + View.ConstructorArgs = [| render :> obj |] } + + view :> IView \ No newline at end of file diff --git a/src/Examples/Examples.ComponentPlayground/Program.fs b/src/Examples/Examples.ComponentPlayground/Program.fs index dfc8f0cc..3c80e77d 100644 --- a/src/Examples/Examples.ComponentPlayground/Program.fs +++ b/src/Examples/Examples.ComponentPlayground/Program.fs @@ -5,6 +5,7 @@ open Avalonia.Controls.ApplicationLifetimes open Avalonia.Media open Avalonia.Themes.Fluent open Avalonia.FuncUI.Hosts +open Avalonia.FuncUI.Experimental open Avalonia.Controls module Helpers = @@ -19,7 +20,7 @@ module Main = open Avalonia.Layout let counterViewNegativeIndicator () = - Component.create (fun _ -> + Component.create ("indicator", fun _ -> TextBlock.create [ TextBlock.fontSize 24.0 TextBlock.verticalAlignment VerticalAlignment.Center @@ -30,7 +31,19 @@ module Main = ) let counterView (count: int) = - Component.create (fun _ -> + ClosureComponent.create (fun ctx -> + let innerCount = ctx.useState count + + ctx.useEffect ( + handler = (fun () -> + Console.WriteLine($"CounterView rendered (count: {count}, innerCount: {innerCount.Current})") + + if innerCount.Current <> count then + innerCount .= count + ), + triggers = [ EffectTrigger.AfterRender ] + ) + StackPanel.create [ StackPanel.dock Dock.Top StackPanel.spacing 5 From 757ac0e4edac6925954cf73e6ca1656270fc8dad Mon Sep 17 00:00:00 2001 From: Josua Jaeger Date: Sun, 14 Apr 2024 22:07:51 +0200 Subject: [PATCH 6/7] move closure components to experimental --- src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj | 2 +- src/Avalonia.FuncUI/DSL/Component.fs | 31 +-------------- .../Experimental.ClosureComponent.fs} | 39 ++++++++++++++++++- 3 files changed, 39 insertions(+), 33 deletions(-) rename src/Avalonia.FuncUI/{Components/ClosureComponent.fs => Experimental/Experimental.ClosureComponent.fs} (50%) diff --git a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj index 307eaaf5..99c9bf4b 100644 --- a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj +++ b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj @@ -51,7 +51,6 @@ - @@ -171,6 +170,7 @@ + diff --git a/src/Avalonia.FuncUI/DSL/Component.fs b/src/Avalonia.FuncUI/DSL/Component.fs index 5661661e..bd3fa6c6 100644 --- a/src/Avalonia.FuncUI/DSL/Component.fs +++ b/src/Avalonia.FuncUI/DSL/Component.fs @@ -15,33 +15,4 @@ type Component with ] View.Outlet = ValueNone View.ConstructorArgs = [| render :> obj |] } - :> IView - -type ClosureComponent with - - static member internal renderFunction<'t when 't :> ClosureComponent>(value: IComponentContext -> IView) : IAttr<'t> = - AttrBuilder<'t>.CreateProperty IView>(ClosureComponent.RenderFunctionProperty, value, ValueNone) - - static member create(key: string, render: IComponentContext -> IView) : IView = - let view: View = - { View.ViewType = typeof - View.ViewKey = ValueSome key - View.Attrs = [ - ClosureComponent.renderFunction render - ] - View.Outlet = ValueNone - View.ConstructorArgs = [| render :> obj |] } - - view :> IView - - static member create(render: IComponentContext -> IView) : IView = - let view: View = - { View.ViewType = typeof - View.ViewKey = ValueNone - View.Attrs = [ - ClosureComponent.renderFunction render - ] - View.Outlet = ValueNone - View.ConstructorArgs = [| render :> obj |] } - - view :> IView \ No newline at end of file + :> IView \ No newline at end of file diff --git a/src/Avalonia.FuncUI/Components/ClosureComponent.fs b/src/Avalonia.FuncUI/Experimental/Experimental.ClosureComponent.fs similarity index 50% rename from src/Avalonia.FuncUI/Components/ClosureComponent.fs rename to src/Avalonia.FuncUI/Experimental/Experimental.ClosureComponent.fs index d9b35a4d..0399397e 100644 --- a/src/Avalonia.FuncUI/Components/ClosureComponent.fs +++ b/src/Avalonia.FuncUI/Experimental/Experimental.ClosureComponent.fs @@ -1,10 +1,11 @@ -namespace Avalonia.FuncUI +namespace Avalonia.FuncUI.Experimental open System open System.Diagnostics.CodeAnalysis open Avalonia open Avalonia.FuncUI open Avalonia.FuncUI.Types +open Avalonia.FuncUI.Builder /// Component that works well with a render function that captures state. [] @@ -41,4 +42,38 @@ type ClosureComponent (render: IComponentContext -> IView) as this = () override this.Render ctx = - _renderFunction ctx \ No newline at end of file + _renderFunction ctx + + + +[] +module __ClosureComponentExtensions = + + type ClosureComponent with + + static member internal renderFunction<'t when 't :> ClosureComponent>(value: IComponentContext -> IView) : IAttr<'t> = + AttrBuilder<'t>.CreateProperty IView>(ClosureComponent.RenderFunctionProperty, value, ValueNone) + + static member create(key: string, render: IComponentContext -> IView) : IView = + let view: View = + { View.ViewType = typeof + View.ViewKey = ValueSome key + View.Attrs = [ + ClosureComponent.renderFunction render + ] + View.Outlet = ValueNone + View.ConstructorArgs = [| render :> obj |] } + + view :> IView + + static member create(render: IComponentContext -> IView) : IView = + let view: View = + { View.ViewType = typeof + View.ViewKey = ValueNone + View.Attrs = [ + ClosureComponent.renderFunction render + ] + View.Outlet = ValueNone + View.ConstructorArgs = [| render :> obj |] } + + view :> IView \ No newline at end of file From 42dbcd60bfa73cc9bad9ad7452c84926a6d400b9 Mon Sep 17 00:00:00 2001 From: Josua Jaeger Date: Fri, 26 Apr 2024 10:27:39 +0200 Subject: [PATCH 7/7] add extension to read env state on any control instance --- .../Experimental.EnvironmentState.fs | 16 +++++++++++++--- .../Examples.TodoApp/ModalHost.fs | 5 +---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.FuncUI/Experimental/Experimental.EnvironmentState.fs b/src/Avalonia.FuncUI/Experimental/Experimental.EnvironmentState.fs index a9d6f3ed..c8fec5a6 100644 --- a/src/Avalonia.FuncUI/Experimental/Experimental.EnvironmentState.fs +++ b/src/Avalonia.FuncUI/Experimental/Experimental.EnvironmentState.fs @@ -68,17 +68,27 @@ module private EnvironmentStateConsumer = else tryFindNext (ancestor, state) + [] -module __ContextExtensions_useEnvHook = +module __Control_useEnvValue = - type IComponentContext with + type Control with member this.readEnvValue(state: EnvironmentState<'value>) : 'value = - match EnvironmentStateConsumer.tryFind (this.control, state), state.DefaultValue with + match EnvironmentStateConsumer.tryFind (this, state), state.DefaultValue with | ValueSome value, _ -> value | ValueNone, Some defaultValue -> defaultValue | ValueNone, None -> failwithf "No value provided for environment value '%s'" state.Name + +[] +module __ContextExtensions_useEnvHook = + + type IComponentContext with + + member this.readEnvValue(state: EnvironmentState<'value>) : 'value = + this.control.readEnvValue(state) + member this.useEnvState(state: EnvironmentState>, ?renderOnChange: bool) : IWritable<'value> = this.usePassedLazy ( obtainValue = (fun () -> this.readEnvValue(state)), diff --git a/src/Examples/Component Examples/Examples.TodoApp/ModalHost.fs b/src/Examples/Component Examples/Examples.TodoApp/ModalHost.fs index 3214209d..343cec35 100644 --- a/src/Examples/Component Examples/Examples.TodoApp/ModalHost.fs +++ b/src/Examples/Component Examples/Examples.TodoApp/ModalHost.fs @@ -102,7 +102,4 @@ module __ContextExtensions_useModal = type IComponentContext with member this.useModalState() : ModalHostState = - this.readEnvValue ModalHost.State - - - + this.readEnvValue ModalHost.State \ No newline at end of file