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..99c9bf4b 100644 --- a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj +++ b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj @@ -56,6 +56,7 @@ + @@ -169,6 +170,7 @@ + diff --git a/src/Avalonia.FuncUI/Components/Component.fs b/src/Avalonia.FuncUI/Components/Component.fs index ad313375..035db30f 100644 --- a/src/Avalonia.FuncUI/Components/Component.fs +++ b/src/Avalonia.FuncUI/Components/Component.fs @@ -1,12 +1,8 @@ namespace Avalonia.FuncUI -open System open System.Diagnostics.CodeAnalysis -open Avalonia.Controls open Avalonia.FuncUI open Avalonia.FuncUI.Types -open Avalonia.FuncUI.VirtualDom -open Avalonia.Threading [] [] @@ -14,14 +10,4 @@ type Component (render: IComponentContext -> IView) as this = inherit ComponentBase () override this.Render ctx = - render ctx - -type Component with - - 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 + render 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 new file mode 100644 index 00000000..bd3fa6c6 --- /dev/null +++ b/src/Avalonia.FuncUI/DSL/Component.fs @@ -0,0 +1,18 @@ +[] +module Avalonia.FuncUI.DSL.__ComponentExtensions + +open Avalonia.FuncUI +open Avalonia.FuncUI.Builder +open Avalonia.FuncUI.Types + +type Component with + + 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 \ No newline at end of file diff --git a/src/Avalonia.FuncUI/Experimental/Experimental.ClosureComponent.fs b/src/Avalonia.FuncUI/Experimental/Experimental.ClosureComponent.fs new file mode 100644 index 00000000..0399397e --- /dev/null +++ b/src/Avalonia.FuncUI/Experimental/Experimental.ClosureComponent.fs @@ -0,0 +1,79 @@ +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. +[] +[] +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 + + + +[] +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 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/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/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 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..3c80e77d --- /dev/null +++ b/src/Examples/Examples.ComponentPlayground/Program.fs @@ -0,0 +1,121 @@ +namespace Examples.CounterApp +open System +open Avalonia +open Avalonia.Controls.ApplicationLifetimes +open Avalonia.Media +open Avalonia.Themes.Fluent +open Avalonia.FuncUI.Hosts +open Avalonia.FuncUI.Experimental +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 counterViewNegativeIndicator () = + Component.create ("indicator", fun _ -> + TextBlock.create [ + TextBlock.fontSize 24.0 + TextBlock.verticalAlignment VerticalAlignment.Center + TextBlock.horizontalAlignment HorizontalAlignment.Center + TextBlock.background (SolidColorBrush.Parse (Helpers.randomColor())) + TextBlock.text "Negative" + ] + ) + + let counterView (count: int) = + 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 + 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 () + ] + ] + ) + + 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)