├── .github ├── FUNDING.yml └── workflows │ ├── pull_request.yml │ ├── build.yml │ └── release.yml ├── logo ├── logo-title.png └── nuget-icon.png ├── .editorconfig ├── src ├── Fabulous │ ├── README.md │ ├── Reconciler.fs │ ├── Lifecycle.fs │ ├── MapMsg.fs │ ├── WidgetDefinitions.fs │ ├── Logger.fs │ ├── Components │ │ ├── Environment.fs │ │ ├── EnvironmentObject.fs │ │ ├── Binding.fs │ │ ├── Mvu.fs │ │ ├── Builder.fs │ │ ├── ComponentContext.fs │ │ ├── State.fs │ │ ├── Widget.fs │ │ ├── Component.fs │ │ └── README.md │ ├── ViewRef.fs │ ├── IViewNode.fs │ ├── View.fs │ ├── Dispatch.fs │ ├── Fabulous.fsproj │ ├── Runner.fs │ ├── Sub.fs │ ├── Memo.fs │ ├── EnvironmentContext.fs │ ├── Primitives.fs │ ├── Program.fs │ ├── ViewNode.fs │ ├── AttributeDefinitions.fs │ └── Cmd.fs ├── Fabulous.Tests │ ├── APISketchTests │ │ ├── TestUI.ViewNode.fs │ │ ├── TestUI.Component.fs │ │ ├── TestUI.ViewUpdaters.fs │ │ ├── TestUI.Platform.fs │ │ ├── TestUI.Attributes.fs │ │ └── TestUI.Widgets.fs │ ├── ArrayTests.fs │ ├── Generators.fs │ ├── ViewTests.fs │ ├── CmdTests.fs │ ├── Fabulous.Tests.fsproj │ └── AttributesTests.fs └── Fabulous.Benchmarks │ ├── Fabulous.Benchmarks.fsproj │ ├── snapshots │ ├── macos-m1-2021-12-18 │ │ ├── Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report-github.md │ │ ├── Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report-github.md │ │ ├── Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.html │ │ ├── Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.html │ │ ├── Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.csv │ │ └── Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.csv │ ├── win-i5-9600kf-2021-12-21 │ │ ├── Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report-github.md │ │ ├── Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report-github.md │ │ ├── Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.csv │ │ ├── Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.csv │ │ ├── Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.html │ │ └── Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.html │ └── macos-i7-6820hq-2021-12-21 │ │ ├── Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report-github.md │ │ ├── Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report-github.md │ │ ├── Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.csv │ │ ├── Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.csv │ │ ├── Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.html │ │ └── Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.html │ └── Benchmarks.fs ├── .config └── dotnet-tools.json ├── Directory.Packages.props ├── Directory.Build.props ├── Fabulous.sln ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── README.md └── .gitignore /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [TimLariviere, edgarfgp] 2 | -------------------------------------------------------------------------------- /logo/logo-title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabulous-dev/Fabulous/HEAD/logo/logo-title.png -------------------------------------------------------------------------------- /logo/nuget-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabulous-dev/Fabulous/HEAD/logo/nuget-icon.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.fs] 2 | max_line_length=160 3 | fsharp_space_before_lowercase_invocation = false 4 | fsharp_max_dot_get_expression_width = 60 -------------------------------------------------------------------------------- /src/Fabulous/README.md: -------------------------------------------------------------------------------- 1 | Fabulous combines the power of functional programming and the simple Model-View-Update architecture to build any kind of mobile and desktop applications with an expressive, dynamic and clean UI DSL -------------------------------------------------------------------------------- /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "fantomas": { 6 | "version": "7.0.0", 7 | "commands": [ 8 | "fantomas" 9 | ], 10 | "rollForward": false 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/Fabulous.Tests/APISketchTests/TestUI.ViewNode.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests.APISketchTests 2 | 3 | module TestUI_ViewNode = 4 | 5 | open Fabulous 6 | open Platform 7 | 8 | module ViewNode = 9 | let ViewNodeProperty = "ViewNodeProperty" 10 | 11 | let getViewNode (target: obj) = 12 | (target :?> TestViewElement).PropertyBag.Item ViewNodeProperty :?> ViewNode :> IViewNode 13 | -------------------------------------------------------------------------------- /src/Fabulous/Reconciler.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open Fabulous 4 | 5 | module Reconciler = 6 | 7 | let update (canReuseView: Widget -> Widget -> bool) (prevOpt: Widget voption) (next: Widget) (node: IViewNode) : unit = 8 | 9 | let diff = 10 | WidgetDiff.create(prevOpt, next, canReuseView, fun key a b -> (AttributeDefinitionStore.getScalar key).CompareBoxed a b) 11 | 12 | node.ApplyDiff(&diff) 13 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/Fabulous.Benchmarks.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Exe 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Fabulous.Tests/APISketchTests/TestUI.Component.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests.APISketchTests 2 | 3 | module TestUI_Component = 4 | 5 | open Fabulous 6 | open Platform 7 | 8 | module Component = 9 | let ComponentProperty = "ComponentProperty" 10 | 11 | let getComponent (target: obj) = 12 | match (target :?> TestViewElement).PropertyBag.TryGetValue(ComponentProperty) with 13 | | true, comp -> comp 14 | | _ -> null 15 | 16 | let setComponent (comp: obj) (target: obj) = 17 | (target :?> TestViewElement).PropertyBag.Add(ComponentProperty, comp) 18 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-m1-2021-12-18/Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` ini 2 | 3 | BenchmarkDotNet=v0.13.1, OS=macOS Big Sur 11.5.1 (20G80) [Darwin 20.6.0] 4 | Apple M1, 1 CPU, 8 logical and 8 physical cores 5 | .NET SDK=6.0.100 6 | [Host] : .NET 6.0.0 (6.0.21.52210), Arm64 RyuJIT DEBUG 7 | .NET 6.0 : .NET 6.0.0 (6.0.21.52210), Arm64 RyuJIT 8 | 9 | Job=.NET 6.0 Runtime=.NET 6.0 10 | 11 | ``` 12 | | Method | depth | Mean | Error | StdDev | 13 | |-------------- |------ |-----------:|---------:|----------:| 14 | | **CreateWidgets** | **100** | **127.1 μs** | **0.20 μs** | **0.16 μs** | 15 | | **CreateWidgets** | **1000** | **2,845.5 μs** | **56.45 μs** | **116.58 μs** | 16 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | on: pull_request 3 | 4 | env: 5 | SLN_FILE: Fabulous.sln 6 | CONFIG: Release 7 | 8 | jobs: 9 | pull_request: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout sources 13 | uses: actions/checkout@v3 14 | - name: Setup .NET 15 | uses: actions/setup-dotnet@v3 16 | with: 17 | dotnet-version: 9.x 18 | - name: Check code formatting 19 | run: | 20 | dotnet tool restore 21 | dotnet fantomas --check src 22 | - name: Restore 23 | run: dotnet restore ${SLN_FILE} 24 | - name: Build 25 | run: dotnet build ${SLN_FILE} -c ${CONFIG} --no-restore 26 | - name: Test 27 | run: dotnet test ${SLN_FILE} -c ${CONFIG} --no-build -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-m1-2021-12-18/Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` ini 2 | 3 | BenchmarkDotNet=v0.13.1, OS=macOS Big Sur 11.5.1 (20G80) [Darwin 20.6.0] 4 | Apple M1, 1 CPU, 8 logical and 8 physical cores 5 | .NET SDK=6.0.100 6 | [Host] : .NET 6.0.0 (6.0.21.52210), Arm64 RyuJIT DEBUG 7 | .NET 6.0 : .NET 6.0.0 (6.0.21.52210), Arm64 RyuJIT 8 | 9 | Job=.NET 6.0 Runtime=.NET 6.0 10 | 11 | ``` 12 | | Method | depth | Mean | Error | StdDev | 13 | |---------------- |------ |------------:|----------:|----------:| 14 | | **ProcessMessages** | **100** | **61.72 ms** | **0.560 ms** | **0.468 ms** | 15 | | **ProcessMessages** | **1000** | **1,438.17 ms** | **23.754 ms** | **21.057 ms** | 16 | -------------------------------------------------------------------------------- /src/Fabulous/Lifecycle.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open Fabulous.ScalarAttributeDefinitions 4 | 5 | module Lifecycle = 6 | let inline private createAttribute name : SimpleScalarAttributeDefinition = 7 | let key = 8 | SimpleScalarAttributeDefinition.CreateAttributeData((fun _ _ -> ScalarAttributeComparison.Identical), (fun _oldValueOpt _newValueOpt _node -> ())) 9 | |> AttributeDefinitionStore.registerScalar 10 | 11 | { Key = key; Name = name } 12 | 13 | /// Store an event that will be triggered when a Widget has been mounted in the UI tree 14 | let Mounted = createAttribute "Mounted" 15 | 16 | /// Store an event that will be triggered when a Widget has been unmounted from the UI tree 17 | let Unmounted = createAttribute "Unmounted" 18 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Fabulous/MapMsg.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open Fabulous.ScalarAttributeDefinitions 4 | 5 | module MapMsg = 6 | /// Store a map function to convert a child message to a parent message. 7 | /// Help compose independent views using different MVU cycle and messages 8 | let MapMsg: SimpleScalarAttributeDefinition obj> = 9 | let key = 10 | SimpleScalarAttributeDefinition.CreateAttributeData( 11 | (fun _ _ -> ScalarAttributeComparison.Different), 12 | (fun _oldValueOpt newValueOpt node -> 13 | match newValueOpt with 14 | | ValueNone -> node.MapMsg <- None 15 | | ValueSome fn -> node.MapMsg <- Some fn) 16 | ) 17 | |> AttributeDefinitionStore.registerScalar 18 | 19 | { Key = key; Name = "Fabulous_MapMsg" } 20 | -------------------------------------------------------------------------------- /src/Fabulous.Tests/ArrayTests.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests 2 | 3 | open System 4 | open Fabulous.StackAllocatedCollections 5 | open NUnit.Framework 6 | 7 | [] 8 | type ``Array tests``() = 9 | [] 10 | member _.``MutStackArray1.combineMut reuses array B if can fit all data``() = 11 | let arrB = Array.zeroCreate 7 12 | 13 | let a = MutStackArray1.Many((2us, Array.zeroCreate 4)) 14 | let b = MutStackArray1.Many((5us, arrB)) 15 | let c = MutStackArray1.combineMut(&a, b) 16 | let cOpt = MutStackArray1.toArraySlice &c 17 | let struct (usedC, arrC) = cOpt.Value 18 | 19 | // We should have the same number of used items 20 | Assert.AreEqual(7us, usedC) 21 | 22 | // Reference should be equal to arrB since the array was reused 23 | Assert.True(Object.ReferenceEquals(arrC, arrB)) 24 | -------------------------------------------------------------------------------- /src/Fabulous/WidgetDefinitions.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open Fabulous 5 | 6 | /// Widget definition to create a control 7 | type WidgetDefinition = 8 | { Key: WidgetKey 9 | Name: string 10 | TargetType: Type 11 | CreateView: Widget * EnvironmentContext * ViewTreeContext * IViewNode voption -> struct (IViewNode * obj) 12 | AttachView: Widget * EnvironmentContext * ViewTreeContext * IViewNode voption * obj -> IViewNode } 13 | 14 | module WidgetDefinitionStore = 15 | let private _widgets = ResizeArray() 16 | 17 | let mutable private _nextKey = 0 18 | 19 | let get key = _widgets[key] 20 | let set key value = _widgets[key] <- value 21 | 22 | let getNextKey () : WidgetKey = 23 | _widgets.Add(Unchecked.defaultof) 24 | let key = _nextKey 25 | _nextKey <- _nextKey + 1 26 | key 27 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/win-i5-9600kf-2021-12-21/Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` ini 2 | 3 | BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000 4 | Intel Core i5-9600KF CPU 3.70GHz (Coffee Lake), 1 CPU, 6 logical and 6 physical cores 5 | .NET SDK=6.0.101 6 | [Host] : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT DEBUG 7 | .NET 6.0 : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT 8 | 9 | Job=.NET 6.0 Runtime=.NET 6.0 10 | 11 | ``` 12 | | Method | depth | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated | 13 | |---------------- |------ |-----------:|---------:|---------:|------------:|------------:|-----------:|----------:| 14 | | **ProcessMessages** | **10** | **173.7 ms** | **2.13 ms** | **1.89 ms** | **40000.0000** | **5000.0000** | **2000.0000** | **222 MB** | 15 | | **ProcessMessages** | **15** | **4,920.9 ms** | **63.42 ms** | **59.32 ms** | **510000.0000** | **196000.0000** | **96000.0000** | **2,470 MB** | 16 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/win-i5-9600kf-2021-12-21/Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` ini 2 | 3 | BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000 4 | Intel Core i5-9600KF CPU 3.70GHz (Coffee Lake), 1 CPU, 6 logical and 6 physical cores 5 | .NET SDK=6.0.101 6 | [Host] : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT DEBUG 7 | .NET 6.0 : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT 8 | 9 | Job=.NET 6.0 Runtime=.NET 6.0 10 | 11 | ``` 12 | | Method | depth | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated | 13 | |-------------- |------ |-------------:|------------:|------------:|-----------:|----------:|---------:|----------:| 14 | | **CreateWidgets** | **10** | **323.7 μs** | **1.85 μs** | **1.54 μs** | **106.4453** | **38.5742** | **-** | **551 KB** | 15 | | **CreateWidgets** | **20** | **101,939.1 μs** | **1,878.27 μs** | **1,756.94 μs** | **11600.0000** | **2800.0000** | **800.0000** | **68,072 KB** | 16 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-i7-6820hq-2021-12-21/Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` ini 2 | 3 | BenchmarkDotNet=v0.13.1, OS=macOS Monterey 12.1 (21C52) [Darwin 21.2.0] 4 | Intel Core i7-6820HQ CPU 2.70GHz (Skylake), 1 CPU, 8 logical and 4 physical cores 5 | .NET SDK=6.0.101 6 | [Host] : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT DEBUG 7 | .NET 6.0 : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT 8 | 9 | Job=.NET 6.0 Runtime=.NET 6.0 10 | 11 | ``` 12 | | Method | depth | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated | 13 | |---------------- |------ |-----------:|---------:|---------:|------------:|------------:|-----------:|----------:| 14 | | **ProcessMessages** | **10** | **257.5 ms** | **3.79 ms** | **3.36 ms** | **46000.0000** | **14000.0000** | **2000.0000** | **222 MB** | 15 | | **ProcessMessages** | **15** | **7,457.8 ms** | **33.21 ms** | **31.06 ms** | **605000.0000** | **196000.0000** | **96000.0000** | **2,470 MB** | 16 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-i7-6820hq-2021-12-21/Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report-github.md: -------------------------------------------------------------------------------- 1 | ``` ini 2 | 3 | BenchmarkDotNet=v0.13.1, OS=macOS Monterey 12.1 (21C52) [Darwin 21.2.0] 4 | Intel Core i7-6820HQ CPU 2.70GHz (Skylake), 1 CPU, 8 logical and 4 physical cores 5 | .NET SDK=6.0.101 6 | [Host] : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT DEBUG 7 | .NET 6.0 : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT 8 | 9 | Job=.NET 6.0 Runtime=.NET 6.0 10 | 11 | ``` 12 | | Method | depth | Mean | Error | StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated | 13 | |-------------- |------ |-------------:|------------:|------------:|-----------:|----------:|---------:|----------:| 14 | | **CreateWidgets** | **10** | **439.6 μs** | **3.39 μs** | **3.00 μs** | **120.6055** | **45.4102** | **-** | **551 KB** | 15 | | **CreateWidgets** | **20** | **159,309.5 μs** | **3,076.23 μs** | **3,159.06 μs** | **14000.0000** | **3000.0000** | **750.0000** | **68,072 KB** | 16 | -------------------------------------------------------------------------------- /src/Fabulous.Tests/Generators.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests 2 | 3 | open FsCheck 4 | open NUnit.Framework 5 | 6 | type IntTypedEnum = 7 | | One = 1 8 | | Two = 2 9 | | Three = 3 10 | | Four = 4 11 | | Five = 5 12 | | Six = 6 13 | 14 | module SmallScalarGenerators = 15 | let intTypedEnum = 16 | gen { 17 | return! 18 | Gen.elements 19 | [ IntTypedEnum.One 20 | IntTypedEnum.Two 21 | IntTypedEnum.Three 22 | IntTypedEnum.Four 23 | IntTypedEnum.Five 24 | IntTypedEnum.Five 25 | IntTypedEnum.Six ] 26 | } 27 | 28 | type Generators = 29 | static member IntTypedEnum() = 30 | { new Arbitrary() with 31 | member this.Generator = SmallScalarGenerators.intTypedEnum } 32 | 33 | [] 34 | type Setup() = 35 | [] 36 | member _.Setup() = do Arb.register() |> ignore 37 | -------------------------------------------------------------------------------- /src/Fabulous.Tests/ViewTests.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests 2 | 3 | open Fabulous 4 | open Fabulous.StackAllocatedCollections.StackList 5 | open NUnit.Framework 6 | 7 | type ITestControl = interface end 8 | 9 | module TestControl = 10 | let WidgetKey: WidgetKey = 1 11 | 12 | type Msg = ChildMsg of unit 13 | 14 | [] 15 | type ``View helper functions tests``() = 16 | 17 | /// Test: https://github.com/fabulous-dev/Fabulous/pull/1037 18 | [] 19 | member _.``Mapping a WidgetBuilder with Unit Msg to another Msg is supported``() = 20 | let widgetBuilder = WidgetBuilder(TestControl.WidgetKey) 21 | 22 | let mapMsg (oldMsg: unit) = ChildMsg oldMsg 23 | 24 | let mappedWidgetBuilder = View.map mapMsg widgetBuilder 25 | 26 | Assert.AreEqual(widgetBuilder.Key, mappedWidgetBuilder.Key) 27 | 28 | let struct (scalars, _, _, _) = mappedWidgetBuilder.Attributes 29 | let scalars = StackList.toArray &scalars 30 | 31 | Assert.AreEqual(1, scalars.Length) 32 | Assert.AreEqual(MapMsg.MapMsg.Key, scalars[0].Key) 33 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://github.com/fabulous-dev/Fabulous 5 | true 6 | true 7 | false 8 | 9 | 10 | 11 | 12 | Fabulous contributors 13 | Fabulous;F#;Declarative UI;MVU 14 | nuget-icon.png 15 | README.md 16 | Apache-2.0 17 | https://github.com/fabulous-dev/Fabulous 18 | snupkg 19 | 20 | 21 | 22 | $(OtherFlags) --test:GraphBasedChecking --test:ParallelOptimization --test:ParallelIlxGen 23 | true 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-m1-2021-12-18/Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-20211218-230238 6 | 7 | 13 | 14 | 15 |

16 | BenchmarkDotNet=v0.13.1, OS=macOS Big Sur 11.5.1 (20G80) [Darwin 20.6.0]
17 | Apple M1, 1 CPU, 8 logical and 8 physical cores
18 | .NET SDK=6.0.100
19 |   [Host]   : .NET 6.0.0 (6.0.21.52210), Arm64 RyuJIT DEBUG
20 |   .NET 6.0 : .NET 6.0.0 (6.0.21.52210), Arm64 RyuJIT
21 | 
22 |
Job=.NET 6.0  Runtime=.NET 6.0  
23 | 
24 | 25 | 26 | 27 | 28 | 29 | 30 |
Methoddepth MeanErrorStdDev
ProcessMessages10061.72 ms0.560 ms0.468 ms
ProcessMessages10001,438.17 ms23.754 ms21.057 ms
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Fabulous.Tests/CmdTests.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests 2 | 3 | open Fabulous 4 | open NUnit.Framework 5 | 6 | type CmdTestsMsg = NewValue of int 7 | 8 | module CmdTestsHelper = 9 | let execute dispatch (cmd: Cmd<'msg>) = 10 | for sub in cmd do 11 | sub dispatch 12 | 13 | [] 14 | type ``Cmd tests``() = 15 | [] 16 | member _.``Cmd.debounce only dispatch the last message``() = 17 | async { 18 | let mutable actualValue = None 19 | 20 | let dispatch msg = 21 | if actualValue.IsNone then 22 | actualValue <- Some msg 23 | 24 | let triggerCmd = Cmd.debounce 100 NewValue 25 | 26 | triggerCmd 1 |> CmdTestsHelper.execute dispatch 27 | do! Async.Sleep 50 28 | triggerCmd 2 |> CmdTestsHelper.execute dispatch 29 | do! Async.Sleep 75 30 | triggerCmd 3 |> CmdTestsHelper.execute dispatch 31 | do! Async.Sleep 125 32 | 33 | Assert.AreEqual(Some(NewValue 3), actualValue) 34 | 35 | actualValue <- None 36 | 37 | triggerCmd 4 |> CmdTestsHelper.execute dispatch 38 | do! Async.Sleep 75 39 | triggerCmd 5 |> CmdTestsHelper.execute dispatch 40 | do! Async.Sleep 125 41 | 42 | Assert.AreEqual(Some(NewValue 5), actualValue) 43 | } 44 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-m1-2021-12-18/Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-20211218-230125 6 | 7 | 13 | 14 | 15 |

16 | BenchmarkDotNet=v0.13.1, OS=macOS Big Sur 11.5.1 (20G80) [Darwin 20.6.0]
17 | Apple M1, 1 CPU, 8 logical and 8 physical cores
18 | .NET SDK=6.0.100
19 |   [Host]   : .NET 6.0.0 (6.0.21.52210), Arm64 RyuJIT DEBUG
20 |   .NET 6.0 : .NET 6.0.0 (6.0.21.52210), Arm64 RyuJIT
21 | 
22 |
Job=.NET 6.0  Runtime=.NET 6.0  
23 | 
24 | 25 | 26 | 27 | 28 | 29 | 30 |
MethoddepthMeanErrorStdDev
CreateWidgets100127.1 μs0.20 μs0.16 μs
CreateWidgets10002,845.5 μs56.45 μs116.58 μs
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-m1-2021-12-18/Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.csv: -------------------------------------------------------------------------------- 1 | Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,depth,Mean,Error,StdDev 2 | CreateWidgets,.NET 6.0,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,100,127.1 μs,0.20 μs,0.16 μs 3 | CreateWidgets,.NET 6.0,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,1000,"2,845.5 μs",56.45 μs,116.58 μs 4 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-m1-2021-12-18/Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.csv: -------------------------------------------------------------------------------- 1 | Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,depth,Mean,Error,StdDev 2 | ProcessMessages,.NET 6.0,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,100,61.72 ms,0.560 ms,0.468 ms 3 | ProcessMessages,.NET 6.0,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,Arm64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,1000,"1,438.17 ms",23.754 ms,21.057 ms 4 | -------------------------------------------------------------------------------- /src/Fabulous/Logger.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open System.Runtime.CompilerServices 5 | 6 | type LogLevel = 7 | | Debug = 0 8 | | Info = 1 9 | | Warn = 2 10 | | Error = 3 11 | | Fatal = 4 12 | 13 | [] 14 | type Logger = 15 | { Log: LogLevel * string -> unit 16 | MinLogLevel: LogLevel } 17 | 18 | [] 19 | type LoggerExtensions = 20 | static member inline Log(this: Logger, level: LogLevel, format: string, [] args: obj[]) = 21 | if level >= this.MinLogLevel then 22 | this.Log(level, String.Format(format, args)) 23 | 24 | [] 25 | static member inline Debug(this: Logger, format: string, [] args: obj[]) = 26 | LoggerExtensions.Log(this, LogLevel.Debug, format, args) 27 | 28 | [] 29 | static member inline Info(this: Logger, format: string, [] args: obj[]) = 30 | LoggerExtensions.Log(this, LogLevel.Info, format, args) 31 | 32 | [] 33 | static member inline Warn(this: Logger, format: string, [] args: obj[]) = 34 | LoggerExtensions.Log(this, LogLevel.Warn, format, args) 35 | 36 | [] 37 | static member inline Error(this: Logger, format: string, [] args: obj[]) = 38 | LoggerExtensions.Log(this, LogLevel.Error, format, args) 39 | 40 | [] 41 | static member inline Fatal(this: Logger, ex: exn) = 42 | LoggerExtensions.Log(this, LogLevel.Fatal, "{0}", ex.ToString()) 43 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/win-i5-9600kf-2021-12-21/Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.csv: -------------------------------------------------------------------------------- 1 | Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,depth,Mean,Error,StdDev,Gen 0,Gen 1,Gen 2,Allocated 2 | CreateWidgets,.NET 6.0,False,Default,Default,Default,Default,Default,Default,111111,Empty,RyuJit,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,10,323.7 μs,1.85 μs,1.54 μs,106.4453,38.5742,-,551 KB 3 | CreateWidgets,.NET 6.0,False,Default,Default,Default,Default,Default,Default,111111,Empty,RyuJit,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,20,"101,939.1 μs","1,878.27 μs","1,756.94 μs",11600.0000,2800.0000,800.0000,"68,072 KB" 4 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-i7-6820hq-2021-12-21/Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.csv: -------------------------------------------------------------------------------- 1 | Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,depth,Mean,Error,StdDev,Gen 0,Gen 1,Gen 2,Allocated 2 | CreateWidgets,.NET 6.0,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,10,439.6 μs,3.39 μs,3.00 μs,120.6055,45.4102,-,551 KB 3 | CreateWidgets,.NET 6.0,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,20,"159,309.5 μs","3,076.23 μs","3,159.06 μs",14000.0000,3000.0000,750.0000,"68,072 KB" 4 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/win-i5-9600kf-2021-12-21/Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.csv: -------------------------------------------------------------------------------- 1 | Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,depth,Mean,Error,StdDev,Gen 0,Gen 1,Gen 2,Allocated 2 | ProcessMessages,.NET 6.0,False,Default,Default,Default,Default,Default,Default,111111,Empty,RyuJit,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,10,173.7 ms,2.13 ms,1.89 ms,40000.0000,5000.0000,2000.0000,222 MB 3 | ProcessMessages,.NET 6.0,False,Default,Default,Default,Default,Default,Default,111111,Empty,RyuJit,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,15,"4,920.9 ms",63.42 ms,59.32 ms,510000.0000,196000.0000,96000.0000,"2,470 MB" 4 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-i7-6820hq-2021-12-21/Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.csv: -------------------------------------------------------------------------------- 1 | Method,Job,AnalyzeLaunchVariance,EvaluateOverhead,MaxAbsoluteError,MaxRelativeError,MinInvokeCount,MinIterationTime,OutlierMode,Affinity,EnvironmentVariables,Jit,Platform,PowerPlanMode,Runtime,AllowVeryLargeObjects,Concurrent,CpuGroups,Force,HeapAffinitizeMask,HeapCount,NoAffinitize,RetainVm,Server,Arguments,BuildConfiguration,Clock,EngineFactory,NuGetReferences,Toolchain,IsMutator,InvocationCount,IterationCount,IterationTime,LaunchCount,MaxIterationCount,MaxWarmupIterationCount,MemoryRandomization,MinIterationCount,MinWarmupIterationCount,RunStrategy,UnrollFactor,WarmupCount,depth,Mean,Error,StdDev,Gen 0,Gen 1,Gen 2,Allocated 2 | ProcessMessages,.NET 6.0,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,10,257.5 ms,3.79 ms,3.36 ms,46000.0000,14000.0000,2000.0000,222 MB 3 | ProcessMessages,.NET 6.0,False,Default,Default,Default,Default,Default,Default,00000000,Empty,RyuJit,X64,8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c,.NET 6.0,False,True,False,True,Default,Default,False,False,False,Default,Default,Default,Default,Default,Default,Default,1,Default,Default,Default,Default,Default,Default,Default,Default,Default,16,Default,15,"7,457.8 ms",33.21 ms,31.06 ms,605000.0000,196000.0000,96000.0000,"2,470 MB" 4 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/win-i5-9600kf-2021-12-21/Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-20211221-143350 6 | 7 | 13 | 14 | 15 |

16 | BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
17 | Intel Core i5-9600KF CPU 3.70GHz (Coffee Lake), 1 CPU, 6 logical and 6 physical cores
18 | .NET SDK=6.0.101
19 |   [Host]   : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT DEBUG
20 |   .NET 6.0 : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT
21 | 
22 |
Job=.NET 6.0  Runtime=.NET 6.0  
23 | 
24 | 25 | 26 | 27 | 28 | 29 | 30 |
MethoddepthMeanErrorStdDevGen 0Gen 1Gen 2Allocated
ProcessMessages10173.7 ms2.13 ms1.89 ms40000.00005000.00002000.0000222 MB
ProcessMessages154,920.9 ms63.42 ms59.32 ms510000.0000196000.000096000.00002,470 MB
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/win-i5-9600kf-2021-12-21/Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-20211221-143315 6 | 7 | 13 | 14 | 15 |

16 | BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
17 | Intel Core i5-9600KF CPU 3.70GHz (Coffee Lake), 1 CPU, 6 logical and 6 physical cores
18 | .NET SDK=6.0.101
19 |   [Host]   : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT DEBUG
20 |   .NET 6.0 : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT
21 | 
22 |
Job=.NET 6.0  Runtime=.NET 6.0  
23 | 
24 | 25 | 26 | 27 | 28 | 29 | 30 |
Methoddepth MeanErrorStdDevGen 0Gen 1Gen 2Allocated
CreateWidgets10323.7 μs1.85 μs1.54 μs106.445338.5742-551 KB
CreateWidgets20101,939.1 μs1,878.27 μs1,756.94 μs11600.00002800.0000800.000068,072 KB
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-i7-6820hq-2021-12-21/Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fabulous.Tests.Benchmarks.DiffingAttributes.Benchmarks-20211221-145122 6 | 7 | 13 | 14 | 15 |

16 | BenchmarkDotNet=v0.13.1, OS=macOS Monterey 12.1 (21C52) [Darwin 21.2.0]
17 | Intel Core i7-6820HQ CPU 2.70GHz (Skylake), 1 CPU, 8 logical and 4 physical cores
18 | .NET SDK=6.0.101
19 |   [Host]   : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT DEBUG
20 |   .NET 6.0 : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT
21 | 
22 |
Job=.NET 6.0  Runtime=.NET 6.0  
23 | 
24 | 25 | 26 | 27 | 28 | 29 | 30 |
MethoddepthMeanErrorStdDevGen 0Gen 1Gen 2Allocated
ProcessMessages10257.5 ms3.79 ms3.36 ms46000.000014000.00002000.0000222 MB
ProcessMessages157,457.8 ms33.21 ms31.06 ms605000.0000196000.000096000.00002,470 MB
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/snapshots/macos-i7-6820hq-2021-12-21/Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fabulous.Tests.Benchmarks.NestedTreeCreation.Benchmarks-20211221-145034 6 | 7 | 13 | 14 | 15 |

16 | BenchmarkDotNet=v0.13.1, OS=macOS Monterey 12.1 (21C52) [Darwin 21.2.0]
17 | Intel Core i7-6820HQ CPU 2.70GHz (Skylake), 1 CPU, 8 logical and 4 physical cores
18 | .NET SDK=6.0.101
19 |   [Host]   : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT DEBUG
20 |   .NET 6.0 : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT
21 | 
22 |
Job=.NET 6.0  Runtime=.NET 6.0  
23 | 
24 | 25 | 26 | 27 | 28 | 29 | 30 |
Methoddepth MeanErrorStdDevGen 0Gen 1Gen 2Allocated
CreateWidgets10439.6 μs3.39 μs3.00 μs120.605545.4102-551 KB
CreateWidgets20159,309.5 μs3,076.23 μs3,159.06 μs14000.00003000.0000750.000068,072 KB
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Fabulous.Tests/APISketchTests/TestUI.ViewUpdaters.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests.APISketchTests 2 | 3 | module TestUI_ViewUpdaters = 4 | 5 | open Fabulous 6 | open Platform 7 | 8 | let updateText _ (newValueOpt: string voption) (node: IViewNode) = 9 | let textElement = node.Target :?> IText 10 | textElement.Text <- ValueOption.defaultValue "" newValueOpt 11 | 12 | let updateRecord _ (newValueOpt: bool voption) (node: IViewNode) = 13 | let textElement = node.Target :?> TestLabel 14 | textElement.record <- ValueOption.defaultValue false newValueOpt 15 | 16 | let updateTextColor _ (newValueOpt: string voption) (node: IViewNode) = 17 | let textElement = node.Target :?> IText 18 | textElement.TextColor <- ValueOption.defaultValue "" newValueOpt 19 | 20 | let updateAutomationId _ (newValueOpt: string voption) (node: IViewNode) = 21 | let el = node.Target :?> TestViewElement 22 | el.AutomationId <- ValueOption.defaultValue "" newValueOpt 23 | 24 | 25 | let updateNumericValueOne _ (newValueOpt: uint64 voption) (node: IViewNode) = 26 | let el = node.Target :?> TestNumericBag 27 | el.valueOne <- ValueOption.defaultValue 0UL newValueOpt 28 | 29 | let updateNumericValueTwo _ (newValueOpt: uint64 voption) (node: IViewNode) = 30 | let el = node.Target :?> TestNumericBag 31 | el.valueTwo <- ValueOption.defaultValue 0UL newValueOpt 32 | 33 | let updateNumericValueThree _ (newValueOpt: float voption) (node: IViewNode) = 34 | let el = node.Target :?> TestNumericBag 35 | el.valueThree <- ValueOption.defaultValue 0. newValueOpt 36 | -------------------------------------------------------------------------------- /src/Fabulous.Tests/Fabulous.Tests.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | Library 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | all 29 | runtime; build; native; contentfiles; analyzers; buildtransitive 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/Fabulous.Tests/AttributesTests.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests 2 | 3 | open Fabulous.Tests 4 | open NUnit.Framework 5 | open FsCheck.NUnit 6 | open Fabulous 7 | 8 | [] 9 | type AttributesTests() = 10 | [] 11 | member _.``Encoding then decoding a bool should return an identical bool``(value: bool) = 12 | let encoded = SmallScalars.Bool.encode value 13 | let decoded = SmallScalars.Bool.decode encoded 14 | Assert.AreEqual(value, decoded) 15 | 16 | [] 17 | member _.``Encoding then decoding a float should return an identical float``(value: float) = 18 | let encoded = SmallScalars.Float.encode value 19 | let decoded = SmallScalars.Float.decode encoded 20 | Assert.AreEqual(value, decoded) 21 | 22 | [] 23 | member _.``Encoding then decoding a float32 should return an identical float32``(value: float32) = 24 | let encoded = SmallScalars.Float32.encode value 25 | let decoded = SmallScalars.Float32.decode encoded 26 | Assert.AreEqual(value, decoded) 27 | 28 | [] 29 | member _.``Encoding then decoding an int should return an identical int``(value: int) = 30 | let encoded = SmallScalars.Int.encode value 31 | let decoded = SmallScalars.Int.decode encoded 32 | Assert.AreEqual(value, decoded) 33 | 34 | [] 35 | member _.``Encoding then decoding an int-typed enum should return an identical int-typed enum``(value: IntTypedEnum) = 36 | let encoded = SmallScalars.IntEnum.encode value 37 | 38 | let decoded = SmallScalars.IntEnum.decode encoded 39 | 40 | Assert.AreEqual(value, decoded) 41 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: [ 'main' ] 5 | paths-ignore: [ 'docs/**' ] 6 | 7 | permissions: write-all 8 | 9 | env: 10 | CONFIG: Release 11 | SLN_FILE: Fabulous.sln 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout sources 18 | uses: actions/checkout@v3 19 | - name: Read last version from CHANGELOG.md 20 | id: changelog_reader 21 | uses: mindsers/changelog-reader-action@v2 22 | with: 23 | validation_level: warn 24 | path: ./CHANGELOG.md 25 | - name: Set nightly version 26 | run: | 27 | NIGHTLY_VERSION=${{ steps.changelog_reader.outputs.version }}-nightly-${GITHUB_RUN_ID} 28 | echo "Nightly version is $NIGHTLY_VERSION" 29 | echo "NIGHTLY_VERSION=$NIGHTLY_VERSION" >> "$GITHUB_ENV" 30 | - name: Setup .NET 31 | uses: actions/setup-dotnet@v3 32 | with: 33 | dotnet-version: 9.x 34 | - name: Restore 35 | run: dotnet restore ${SLN_FILE} 36 | - name: Build 37 | run: dotnet build ${SLN_FILE} -p:Version=${NIGHTLY_VERSION} -c ${CONFIG} --no-restore 38 | - name: Test 39 | run: dotnet test ${SLN_FILE} -p:Version=${NIGHTLY_VERSION} -c ${CONFIG} --no-build 40 | - name: Pack 41 | run: dotnet pack ${SLN_FILE} -p:Version=${NIGHTLY_VERSION} -c ${CONFIG} --no-build --property PackageOutputPath=${PWD}/nupkgs 42 | - name: Upload artifacts 43 | uses: actions/upload-artifact@v4 44 | with: 45 | name: Packages 46 | path: nupkgs/ 47 | - name: Push 48 | run: dotnet nuget push "nupkgs/*" -s https://nuget.pkg.github.com/fabulous-dev/index.json -k ${{ secrets.GITHUB_TOKEN }} --skip-duplicate 49 | -------------------------------------------------------------------------------- /src/Fabulous/Components/Environment.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System.Runtime.CompilerServices 4 | open Fabulous.ScalarAttributeDefinitions 5 | 6 | type EnvironmentRequest<'T> = delegate of unit -> EnvironmentKey<'T> 7 | 8 | [] 9 | module EnvironmentBuilders = 10 | type Context with 11 | static member inline Environment(key: EnvironmentKey<'T>) = EnvironmentRequest(fun () -> key) 12 | 13 | type EnvironmentAttrValue = { Key: string; Value: obj } 14 | 15 | [] 16 | type EnvironmentExtensions = 17 | [] 18 | static member inline Bind 19 | ( 20 | _: ComponentBuilder<'parentMsg, 'marker>, 21 | [] value: EnvironmentRequest<'T>, 22 | [] continuation: 'T -> ComponentBodyBuilder<'msg, 'marker> 23 | ) = 24 | ComponentBodyBuilder<'msg, 'marker>(fun envContext treeContext context bindings -> 25 | let envKey = value.Invoke() 26 | let (EnvironmentAttributeKey key) = envKey.Key 27 | 28 | // Listen to changes in the environment 29 | context.LinkDisposable( 30 | $"env_{key}", 31 | fun () -> 32 | envContext.ValueChanged.Subscribe(fun args -> 33 | if args.Key = envKey.Key then 34 | context.NeedsRender()) 35 | ) 36 | |> ignore 37 | 38 | let state = envContext.Get(envKey) 39 | (continuation state).Invoke(envContext, treeContext, context, bindings)) 40 | 41 | [] 42 | type EnvironmentModifiers = 43 | [] 44 | static member inline environment(this: WidgetBuilder<'msg, 'marker>, key: EnvironmentKey<'T>, value: 'T) = this.AddEnvironment(key.Key, value) 45 | -------------------------------------------------------------------------------- /src/Fabulous/ViewRef.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | 5 | /// A reference to be able to access the underlying control of a Widget 6 | type ViewRef<'T when 'T: not struct>() as this = 7 | let attached = Event, 'T>() 8 | let detached = Event() 9 | 10 | let onAttached target = attached.Trigger(this, unbox target) 11 | let onDetached () = detached.Trigger(this, EventArgs()) 12 | 13 | let handle = ViewRef(onAttached, onDetached) 14 | 15 | /// Event triggered when the view is attached 16 | [] 17 | member _.Attached = attached.Publish 18 | 19 | /// Event triggered when the view is detached 20 | [] 21 | member _.Detached = detached.Publish 22 | 23 | /// The underlying control. 24 | /// This property might throw an exception if the control is not attached to the ViewRef 25 | member _.Value: 'T = 26 | match handle.TryValue with 27 | | Some res -> unbox res 28 | | None -> failwith "view reference target has been collected or was not set" 29 | 30 | /// The underlying control. If the control is not attached to the ViewRef, None will be returned 31 | member _.TryValue: 'T option = 32 | match handle.TryValue with 33 | | Some res -> Some(unbox res) 34 | | None -> None 35 | 36 | /// The untyped ViewRef 37 | member _.Unbox = handle 38 | 39 | module ViewRefAttributes = 40 | let ViewRef = 41 | Attributes.defineSimpleScalarWithEquality "Fabulous_ViewRef" (fun oldValueOpt newValueOpt node -> 42 | match oldValueOpt with 43 | | ValueNone -> () 44 | | ValueSome viewRef -> viewRef.Unset() 45 | 46 | match newValueOpt with 47 | | ValueNone -> () 48 | | ValueSome viewRef -> viewRef.Set(node.Target)) 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: ['[0-9]+.[0-9]+.[0-9]+', '[0-9]+.[0-9]+.[0-9]+-pre[0-9]+'] 5 | paths-ignore: [ 'docs/**' ] 6 | 7 | permissions: write-all 8 | 9 | env: 10 | CONFIG: Release 11 | SLN_FILE: Fabulous.sln 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | environment: nuget 17 | steps: 18 | - name: Checkout sources 19 | uses: actions/checkout@v3 20 | - name: Extract version from tag 21 | uses: damienaicheh/extract-version-from-tag-action@v1.0.0 22 | - name: Set release version 23 | run: | 24 | if [ "${PRE_RELEASE}" == "" ]; then 25 | RELEASE_VERSION=${MAJOR}.${MINOR}.${PATCH} 26 | else 27 | RELEASE_VERSION=${MAJOR}.${MINOR}.${PATCH}-${PRE_RELEASE} 28 | fi 29 | echo "Release version is $RELEASE_VERSION" 30 | echo "RELEASE_VERSION=$RELEASE_VERSION" >> "$GITHUB_ENV" 31 | - name: Get Changelog Entry 32 | id: changelog_reader 33 | uses: mindsers/changelog-reader-action@v2 34 | with: 35 | validation_level: warn 36 | version: '${{ env.RELEASE_VERSION }}' 37 | path: ./CHANGELOG.md 38 | - name: Setup .NET 39 | uses: actions/setup-dotnet@v3 40 | with: 41 | dotnet-version: 9.x 42 | - name: Restore 43 | run: dotnet restore ${SLN_FILE} 44 | - name: Build 45 | run: dotnet build ${SLN_FILE} -p:Version=${RELEASE_VERSION} -c ${CONFIG} --no-restore 46 | - name: Test 47 | run: dotnet test ${SLN_FILE} -p:Version=${RELEASE_VERSION} -c ${CONFIG} --no-build 48 | - name: Pack 49 | run: dotnet pack ${SLN_FILE} -p:Version=${RELEASE_VERSION} -p:PackageReleaseNotes="${{ steps.changelog_reader.outputs.changes }}" -c ${CONFIG} --no-build --property PackageOutputPath=${PWD}/nupkgs 50 | - name: Upload artifacts 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: Packages 54 | path: nupkgs/ 55 | - name: Push 56 | run: dotnet nuget push "nupkgs/*" -s https://api.nuget.org/v3/index.json -k ${{ secrets.NUGET_TOKEN }} --skip-duplicate -------------------------------------------------------------------------------- /src/Fabulous/Components/EnvironmentObject.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open System.Runtime.CompilerServices 5 | 6 | type EnvironmentObjectRequest<'T> = delegate of unit -> EnvironmentKey<'T> 7 | 8 | [] 9 | type EnvironmentObject() = 10 | let changed = Event() 11 | member this.Changed = changed.Publish 12 | member this.NotifyChanged() = changed.Trigger() 13 | abstract member Dispose: unit -> unit 14 | default this.Dispose() = () 15 | 16 | interface IDisposable with 17 | member this.Dispose() = this.Dispose() 18 | 19 | [] 20 | module EnvironmentObjectBuilders = 21 | type Context with 22 | static member inline EnvironmentObject<'T when 'T :> EnvironmentObject>(key: EnvironmentKey<'T>) = EnvironmentObjectRequest(fun () -> key) 23 | 24 | [] 25 | type EnvironmentObjectExtensions = 26 | [] 27 | static member inline Bind<'parentMsg, 'marker, 'msg, 'T when 'parentMsg: equality and 'msg: equality and 'T :> EnvironmentObject> 28 | ( 29 | _: ComponentBuilder<'parentMsg, 'marker>, 30 | [] value: EnvironmentObjectRequest<'T>, 31 | [] continuation: 'T -> ComponentBodyBuilder<'msg, 'marker> 32 | ) = 33 | ComponentBodyBuilder<'msg, 'marker>(fun envContext treeContext context bindings -> 34 | let envKey = value.Invoke() 35 | let (EnvironmentAttributeKey key) = envKey.Key 36 | 37 | // Listen to changes in the environment 38 | context.LinkDisposable( 39 | $"env_{key}", 40 | fun () -> 41 | envContext.ValueChanged.Subscribe(fun args -> 42 | if args.Key = envKey.Key then 43 | context.NeedsRender()) 44 | ) 45 | |> ignore 46 | 47 | let state = envContext.Get(envKey) 48 | 49 | // Listen to changes in the object 50 | context.LinkDisposable($"env_{key}_sub", fun () -> state.Changed.Subscribe(fun () -> context.NeedsRender())) 51 | |> ignore 52 | 53 | (continuation state).Invoke(envContext, treeContext, context, bindings)) 54 | -------------------------------------------------------------------------------- /src/Fabulous/Components/Binding.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open System.ComponentModel 5 | open System.Runtime.CompilerServices 6 | 7 | type BindingRequest<'T> = delegate of unit -> StateValue<'T> 8 | 9 | [] 10 | type BindingValue<'T> = 11 | [] 12 | val public SourceContext: ComponentContext 13 | 14 | [] 15 | val public SourceKey: int 16 | 17 | new(stateValue: StateValue<'T>) = 18 | { SourceContext = stateValue.Context 19 | SourceKey = stateValue.Key } 20 | 21 | member this.Current = this.SourceContext.TryGetValue<'T>(this.SourceKey).Value 22 | 23 | member inline this.Set(value: 'T) = 24 | this.SourceContext.SetValue(this.SourceKey, value) 25 | 26 | [] 27 | module BindingBuilders = 28 | type Context with 29 | 30 | static member inline Binding(value: StateValue<'T>) = BindingRequest<'T>(fun () -> value) 31 | 32 | [] 33 | type BindingExtensions = 34 | [] 35 | static member inline Bind 36 | ( 37 | _: ComponentBuilder<'parentMsg, 'marker>, 38 | [] fn: BindingRequest<'T>, 39 | [] continuation: BindingValue<'T> -> ComponentBodyBuilder<'msg, 'marker> 40 | ) = 41 | ComponentBodyBuilder<'msg, 'marker>(fun envContext treeContext context bindings -> 42 | let key = int bindings 43 | let stateValue = fn.Invoke() 44 | 45 | // Dispose previous subscription 46 | match context.TryGetValue(key) with 47 | | ValueNone -> () 48 | | ValueSome d -> d.Dispose() 49 | 50 | // Subscribe to source context changes 51 | let sourceKey = stateValue.Key 52 | 53 | //let sub = 54 | // stateValue.Context.RenderNeeded.Subscribe(fun k -> 55 | // if k = sourceKey then 56 | // ctx.NeedsRender(key)) 57 | // 58 | //ctx.SetValueInternal(key, sub) 59 | 60 | let bindingValue = BindingValue<'T>(stateValue) 61 | 62 | (continuation bindingValue).Invoke(envContext, treeContext, context, bindings + 1)) 63 | -------------------------------------------------------------------------------- /src/Fabulous/Components/Mvu.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System.Runtime.CompilerServices 4 | 5 | type ModelValue<'model> = private ModelValue of 'model 6 | 7 | type MvuRequest<'arg, 'model, 'msg> = delegate of unit -> struct (Program<'arg, 'model, 'msg> * 'arg) 8 | 9 | [] 10 | module MvuBuilders = 11 | type Context with 12 | static member inline Mvu(program: Program) = 13 | MvuRequest(fun () -> program, ()) 14 | 15 | static member inline Mvu(program: Program<'arg, 'model, 'msg>, arg: 'arg) = 16 | MvuRequest<'arg, 'model, 'msg>(fun () -> program, arg) 17 | 18 | [] 19 | type MvuExtensions = 20 | [] 21 | static member Bind 22 | (_: ComponentBuilder<'parentMsg, 'marker>, fn: MvuRequest<'arg, 'model, 'msg>, continuation: 'model -> ComponentBodyBuilder<'msg, 'marker>) 23 | = 24 | ComponentBodyBuilder<'msg, 'marker>(fun envContext treeContext context bindings -> 25 | let key = int bindings 26 | 27 | let struct (treeContext, state) = 28 | match context.TryGetValue(key) with 29 | | ValueSome(ModelValue state) -> treeContext, state 30 | | ValueNone -> 31 | let struct (program, arg) = fn.Invoke() 32 | 33 | let runner = 34 | context.LinkDisposable( 35 | "runner", 36 | fun () -> 37 | let getModel () = 38 | match context.TryGetValue>(key) with 39 | | ValueNone -> failwith("Model not found in ComponentContext " + context.Id.ToString()) 40 | | ValueSome(ModelValue model) -> model 41 | 42 | let setModel v = context.SetValue(key, ModelValue v) 43 | 44 | new Runner<'arg, 'model, 'msg>(getModel, setModel, program) 45 | ) 46 | 47 | // Redirect messages to runner 48 | let treeContext = 49 | { treeContext with 50 | Dispatch = unbox >> runner.Dispatch } 51 | 52 | runner.Start(arg) 53 | 54 | let (ModelValue state) = context.TryGetValue(key).Value 55 | 56 | treeContext, state 57 | 58 | (continuation state).Invoke(envContext, treeContext, context, bindings + 1)) 59 | -------------------------------------------------------------------------------- /src/Fabulous/Components/Builder.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | /// Delegate used by the ComponentBuilder to compose a component body 4 | /// It will be aggressively inlined by the compiler leaving no overhead, only a pure function that returns a WidgetBuilder 5 | type ComponentBodyBuilder<'msg, 'marker when 'msg: equality> = 6 | delegate of 7 | envContext: EnvironmentContext * treeContext: ViewTreeContext * context: ComponentContext * bindings: int -> 8 | struct (EnvironmentContext * ViewTreeContext * ComponentContext * int * WidgetBuilder<'msg, 'marker>) 9 | 10 | type ComponentBuilder<'parentMsg, 'marker when 'parentMsg: equality> = 11 | val public Key: string 12 | 13 | new(key: string) = { Key = key } 14 | 15 | member inline this.Yield(widgetBuilder: WidgetBuilder<'msg, 'marker>) = 16 | ComponentBodyBuilder<'msg, 'marker>(fun envContext treeContext context bindings -> struct (envContext, treeContext, context, bindings, widgetBuilder)) 17 | 18 | member inline this.Combine([] a: ComponentBodyBuilder<'msg, 'marker>, [] b: ComponentBodyBuilder<'msg, 'marker>) = 19 | ComponentBodyBuilder<'msg, 'marker>(fun envContext treeContext context bindings -> 20 | let struct (envA, treeA, ctxA, bindingsA, _) = 21 | a.Invoke(envContext, treeContext, context, bindings) // discard the previous widget in the chain but we still need to count the bindings 22 | 23 | let struct (envB, treeB, ctxB, bindingsB, widgetB) = 24 | b.Invoke(envA, treeA, ctxA, bindingsA) 25 | 26 | // Calculate the total number of bindings between A and B 27 | let combinedBindings = (bindingsA + bindingsB) - bindings 28 | 29 | struct (envB, treeB, ctxB, combinedBindings, widgetB)) 30 | 31 | member inline this.Delay([] fn: unit -> ComponentBodyBuilder<'msg, 'marker>) = 32 | ComponentBodyBuilder<'msg, 'marker>(fun envContext treeContext context bindings -> 33 | let sub = fn() 34 | sub.Invoke(envContext, treeContext, context, bindings)) 35 | 36 | member inline this.Run([] body: ComponentBodyBuilder<'msg, 'marker>) = 37 | let compiledBody = 38 | ComponentBody(fun envContext treeContext context -> 39 | let struct (envA, treeA, ctxA, _, result) = 40 | body.Invoke(envContext, treeContext, context, 0) 41 | 42 | struct (envA, treeA, ctxA, result.Compile())) 43 | 44 | let data = { Key = this.Key; Body = compiledBody } 45 | 46 | WidgetBuilder<'parentMsg, 'marker>(Component'.WidgetKey, Component'.Data.WithValue(data)) 47 | -------------------------------------------------------------------------------- /src/Fabulous/IViewNode.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open Fabulous 5 | 6 | type ViewRef(onAttached, onDetached) = 7 | let handle = System.WeakReference(null) 8 | 9 | /// Check if the new target is the same than the previous one 10 | /// This is done to avoid triggering change events when nothing changes 11 | member private _.IsSameTarget(target) = 12 | match handle.TryGetTarget() with 13 | | true, res when res = target -> true 14 | | _ -> false 15 | 16 | member x.Set(target: obj) : unit = 17 | if not(x.IsSameTarget(target)) then 18 | handle.SetTarget(target) 19 | onAttached target 20 | 21 | member x.Unset() : unit = 22 | if not(x.IsSameTarget(null)) then 23 | handle.SetTarget(null) 24 | onDetached() 25 | 26 | member _.TryValue = 27 | match handle.TryGetTarget() with 28 | | true, null -> None 29 | | true, res -> Some res 30 | | _ -> None 31 | 32 | /// Context of the whole view tree 33 | [] 34 | type ViewTreeContext = 35 | { CanReuseView: Widget -> Widget -> bool 36 | GetViewNode: obj -> IViewNode 37 | Logger: Logger 38 | Dispatch: obj -> unit 39 | SyncAction: (unit -> unit) -> unit 40 | GetComponent: obj -> obj 41 | SetComponent: obj -> obj -> unit } 42 | 43 | and IViewNode = 44 | inherit IDisposable 45 | 46 | /// The view that is being rendered 47 | abstract member Target: obj 48 | 49 | /// The context of the whole view tree 50 | abstract member TreeContext: ViewTreeContext 51 | 52 | /// The environment context 53 | abstract member EnvironmentContext: EnvironmentContext 54 | 55 | // note that Widget is struct type, thus we have boxing via option 56 | // we don't have MemoizedWidget set for 99.9% of the cases 57 | // thus makes sense to have overhead of boxing 58 | // in order to save space 59 | abstract member MemoizedWidget: Widget option with get, set 60 | 61 | /// The parent node 62 | abstract member Parent: IViewNode option 63 | 64 | /// Indicate if the node has been disconnected from the tree 65 | abstract member IsDisconnected: bool 66 | 67 | /// Convert the node messages to its parent's message type 68 | abstract member MapMsg: (obj -> obj) option with get, set 69 | 70 | /// Return the event handler for a given attribute key if set 71 | abstract member TryGetHandler: string -> IDisposable voption 72 | 73 | /// Set the event handler for a given attribute name 74 | abstract member SetHandler: string * IDisposable -> unit 75 | 76 | abstract member RemoveHandler: string -> unit 77 | 78 | /// Apply the diffing result to this node 79 | abstract member ApplyDiff: WidgetDiff inref -> unit 80 | -------------------------------------------------------------------------------- /src/Fabulous/View.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | module ViewHelpers = 4 | let canReuseView (prevWidget: Widget) (currWidget: Widget) = 5 | let prevKey = prevWidget.Key 6 | 7 | if not(prevKey = currWidget.Key) then 8 | false 9 | else if (prevKey = Memo.MemoWidgetKey) then 10 | Memo.canReuseMemoizedWidget prevWidget currWidget 11 | else if (prevKey = Component'.WidgetKey) then 12 | Component'.canReuseComponent prevWidget currWidget 13 | else 14 | true 15 | 16 | module View = 17 | /// Avoid recomputing the whole subtree when the key doesn't change 18 | let lazy'<'msg, 'key, 'marker when 'msg: equality and 'key: equality> 19 | (fn: 'key -> WidgetBuilder<'msg, 'marker>) 20 | (key: 'key) 21 | : WidgetBuilder<'msg, Memo.Memoized<'marker>> = 22 | 23 | let memo: Memo.MemoData = 24 | { KeyData = box key 25 | KeyComparer = fun (prev: obj) (next: obj) -> unbox<'key> prev = unbox<'key> next 26 | CreateWidget = fun k -> fn(unbox<'key> k).Compile() 27 | KeyType = typeof<'key> 28 | MarkerType = typeof<'marker> } 29 | 30 | WidgetBuilder<'msg, Memo.Memoized<'marker>>(Memo.MemoWidgetKey, Memo.MemoAttribute.WithValue(memo)) 31 | 32 | /// Map the widget's message type to the parent's message type to allow for view composition 33 | let inline map ([] fn: 'oldMsg -> 'newMsg) (x: WidgetBuilder<'oldMsg, 'marker>) : WidgetBuilder<'newMsg, 'marker> = 34 | let replaceWith (oldAttr: ScalarAttribute) = 35 | let fnWithBoxing (msg: obj) = 36 | let oldFn = unbox obj> oldAttr.Value 37 | 38 | if not(isNull msg) && typeof<'newMsg>.IsAssignableFrom(msg.GetType()) then 39 | box msg 40 | else 41 | oldFn msg |> unbox<'oldMsg> |> fn |> box 42 | 43 | { oldAttr with Value = fnWithBoxing } 44 | 45 | let defaultWith () = 46 | let mappedFn (msg: obj) = 47 | if not(isNull msg) && typeof<'newMsg>.IsAssignableFrom(msg.GetType()) then 48 | box msg 49 | else 50 | unbox<'oldMsg> msg |> fn |> box 51 | 52 | MapMsg.MapMsg.WithValue(mappedFn) 53 | 54 | let builder = x.AddOrReplaceScalar(MapMsg.MapMsg.Key, replaceWith, defaultWith) 55 | 56 | WidgetBuilder<'newMsg, 'marker>(builder.Key, builder.Attributes) 57 | 58 | /// Combine map and lazy. Map the widget's message type to the parent's message type, and then memoize it 59 | let inline lazyMap ([] mapFn: 'oldMsg -> 'newMsg) ([] viewFn: 'key -> WidgetBuilder<'oldMsg, 'marker>) (model: 'key) = 60 | lazy' (viewFn >> map mapFn) model 61 | -------------------------------------------------------------------------------- /src/Fabulous/Dispatch.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open Fabulous.ScalarAttributeDefinitions 4 | 5 | module Dispatcher = 6 | let private getCanDispatchAndMapMsg (node: IViewNode) : struct (bool * (obj -> obj)) = 7 | let mutable canDispatch = true 8 | let mutable currentNode = Some node 9 | let mutable mapMsg = id 10 | 11 | while currentNode.IsSome && canDispatch do 12 | mapMsg <- 13 | match currentNode.Value.MapMsg with 14 | | None -> mapMsg 15 | | Some fn -> mapMsg >> fn 16 | 17 | // Disable dispatch if the node has been disconnected from the tree 18 | if currentNode.Value.IsDisconnected then 19 | canDispatch <- false 20 | 21 | currentNode <- currentNode.Value.Parent 22 | 23 | struct (canDispatch, mapMsg) 24 | 25 | /// Dispatch a msg for the current ViewNode 26 | let dispatch (node: IViewNode) msg = 27 | let struct (canDispatch, mapMsg) = getCanDispatchAndMapMsg node 28 | 29 | if canDispatch then 30 | node.TreeContext.Dispatch(mapMsg msg) 31 | 32 | /// Trigger an event for the node and all its descendants declaring the given event definition 33 | let dispatchEventForAllChildren (node: IViewNode) (rootWidget: Widget) (definition: SimpleScalarAttributeDefinition) = 34 | let rec dispatchAndVisitChildren skipMapMsg dispatch widget = 35 | // Check if the current widget has a MapMsg function and apply it before dispatch 36 | let dispatch = 37 | if skipMapMsg then 38 | dispatch 39 | else 40 | match AttributeHelpers.tryFindSimpleScalarAttribute MapMsg.MapMsg widget with 41 | | ValueNone -> dispatch 42 | | ValueSome fn -> fn >> dispatch 43 | 44 | match AttributeHelpers.tryFindSimpleScalarAttribute definition widget with 45 | | ValueNone -> () 46 | | ValueSome msg -> dispatch msg 47 | 48 | match widget.WidgetAttributes with 49 | | [||] -> () 50 | | widgetAttrs -> 51 | for childAttr in widgetAttrs do 52 | dispatchAndVisitChildren false dispatch childAttr.Value 53 | 54 | match widget.WidgetCollectionAttributes with 55 | | [||] -> () 56 | | widgetCollAttrs -> 57 | for widgetCollAttr in widgetCollAttrs do 58 | for childWidget in ArraySlice.toSpan widgetCollAttr.Value do 59 | dispatchAndVisitChildren false dispatch childWidget 60 | 61 | let struct (canDispatch, mapMsg) = getCanDispatchAndMapMsg node 62 | 63 | if canDispatch then 64 | dispatchAndVisitChildren true (mapMsg >> node.TreeContext.Dispatch) rootWidget 65 | -------------------------------------------------------------------------------- /src/Fabulous/Fabulous.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net8.0;netstandard2.1 4 | Fabulous 5 | true 6 | 7 | 8 | 9 | Declarative UI framework with F# and MVU 10 | true 11 | en-US 12 | 13 | 14 | 15 | true 16 | true 17 | true 18 | 19 | 20 | true 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /Fabulous.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0A6931CB-FA8E-4519-8EFA-61BF7E81E301}" 7 | EndProject 8 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fabulous", "src\Fabulous\Fabulous.fsproj", "{F3C378FD-2A04-42FA-9B89-4F691B92CE0D}" 9 | EndProject 10 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fabulous.Benchmarks", "src\Fabulous.Benchmarks\Fabulous.Benchmarks.fsproj", "{3CA02AD8-6BD9-4E8A-857F-BCF800B12247}" 11 | EndProject 12 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Fabulous.Tests", "src\Fabulous.Tests\Fabulous.Tests.fsproj", "{31A18443-E05B-47F1-812E-4FEECCCA06DB}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Files", "_Solution Files", "{20E44B0C-FA62-4DE4-8017-95AAE3B178CE}" 15 | ProjectSection(SolutionItems) = preProject 16 | README.md = README.md 17 | CHANGELOG.md = CHANGELOG.md 18 | LICENSE.md = LICENSE.md 19 | .gitignore = .gitignore 20 | pull_request.yml = .github/workflows/pull_request.yml 21 | build.yml = .github/workflows/build.yml 22 | release.yml = .github/workflows/release.yml 23 | .editorconfig = .editorconfig 24 | Directory.Packages.props = Directory.Packages.props 25 | Directory.Build.props = Directory.Build.props 26 | EndProjectSection 27 | EndProject 28 | Global 29 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 30 | Debug|Any CPU = Debug|Any CPU 31 | Release|Any CPU = Release|Any CPU 32 | EndGlobalSection 33 | GlobalSection(SolutionProperties) = preSolution 34 | HideSolutionNode = FALSE 35 | EndGlobalSection 36 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 37 | {F3C378FD-2A04-42FA-9B89-4F691B92CE0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {F3C378FD-2A04-42FA-9B89-4F691B92CE0D}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {F3C378FD-2A04-42FA-9B89-4F691B92CE0D}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {F3C378FD-2A04-42FA-9B89-4F691B92CE0D}.Release|Any CPU.Build.0 = Release|Any CPU 41 | {3CA02AD8-6BD9-4E8A-857F-BCF800B12247}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 42 | {3CA02AD8-6BD9-4E8A-857F-BCF800B12247}.Debug|Any CPU.Build.0 = Debug|Any CPU 43 | {3CA02AD8-6BD9-4E8A-857F-BCF800B12247}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {3CA02AD8-6BD9-4E8A-857F-BCF800B12247}.Release|Any CPU.Build.0 = Release|Any CPU 45 | {31A18443-E05B-47F1-812E-4FEECCCA06DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 46 | {31A18443-E05B-47F1-812E-4FEECCCA06DB}.Debug|Any CPU.Build.0 = Debug|Any CPU 47 | {31A18443-E05B-47F1-812E-4FEECCCA06DB}.Release|Any CPU.ActiveCfg = Release|Any CPU 48 | {31A18443-E05B-47F1-812E-4FEECCCA06DB}.Release|Any CPU.Build.0 = Release|Any CPU 49 | EndGlobalSection 50 | GlobalSection(NestedProjects) = preSolution 51 | {F3C378FD-2A04-42FA-9B89-4F691B92CE0D} = {0A6931CB-FA8E-4519-8EFA-61BF7E81E301} 52 | {3CA02AD8-6BD9-4E8A-857F-BCF800B12247} = {0A6931CB-FA8E-4519-8EFA-61BF7E81E301} 53 | {31A18443-E05B-47F1-812E-4FEECCCA06DB} = {0A6931CB-FA8E-4519-8EFA-61BF7E81E301} 54 | EndGlobalSection 55 | EndGlobal 56 | -------------------------------------------------------------------------------- /src/Fabulous/Runner.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open System.Collections.Concurrent 5 | 6 | // Runners are responsible for the Model-Update part of MVU. 7 | // Runner is created for the component itself. No point in reusing a runner for another component 8 | 9 | /// Create a new Runner handling the update loop for the component 10 | type Runner<'arg, 'model, 'msg>(getState: unit -> 'model, setState: 'model -> unit, program: Program<'arg, 'model, 'msg>) = 11 | let mutable _activeSubs = Sub.Internal.empty 12 | let mutable _reentering = false 13 | let mutable _stopped = false 14 | let queue = ConcurrentQueue<'msg>() 15 | 16 | let onError (message, exn) = 17 | let ex = Exception(message, exn) 18 | 19 | if not(program.ExceptionHandler ex) then 20 | raise ex 21 | 22 | let processMsgs dispatch msg = 23 | let mutable lastMsg = ValueSome msg 24 | 25 | while not _stopped && lastMsg.IsSome do 26 | let model = getState() 27 | let newModel, cmd = program.Update(lastMsg.Value, model) 28 | let subs = program.Subscribe(newModel) 29 | 30 | setState newModel 31 | 32 | _activeSubs <- Sub.Internal.diff _activeSubs subs |> Sub.Internal.Fx.change onError dispatch 33 | 34 | Cmd.exec (fun ex -> onError("Error updating", ex)) dispatch cmd 35 | 36 | lastMsg <- 37 | match queue.TryDequeue() with 38 | | false, _ -> ValueNone 39 | | true, msg -> ValueSome msg 40 | 41 | let rec dispatch msg = 42 | try 43 | if _stopped then 44 | () // Message arrived after Runner got disposed, simply discard it 45 | else if _reentering then 46 | queue.Enqueue(msg) 47 | else 48 | _reentering <- true 49 | processMsgs dispatch msg 50 | _reentering <- false 51 | with ex -> 52 | _reentering <- false 53 | 54 | if not(program.ExceptionHandler ex) then 55 | reraise() 56 | 57 | let start arg = 58 | try 59 | _reentering <- true 60 | 61 | let model, cmd = program.Init(arg) 62 | setState model 63 | 64 | // Start the subscriptions 65 | let subs = program.Subscribe(model) 66 | _activeSubs <- Sub.Internal.diff _activeSubs subs |> Sub.Internal.Fx.change onError dispatch 67 | 68 | // Execute the commands 69 | Cmd.exec (fun ex -> onError("Error initializing", ex)) dispatch cmd 70 | 71 | _reentering <- false 72 | with ex -> 73 | _reentering <- false 74 | 75 | if not(program.ExceptionHandler(ex)) then 76 | reraise() 77 | 78 | let stop () = 79 | try 80 | _stopped <- true 81 | queue.Clear() 82 | Sub.Internal.Fx.stop onError _activeSubs 83 | _activeSubs <- Sub.Internal.empty 84 | with ex -> 85 | _reentering <- false 86 | 87 | if not(program.ExceptionHandler(ex)) then 88 | reraise() 89 | 90 | /// Start the Runner loop 91 | member _.Start(arg) = start arg 92 | 93 | /// Dispatch a message to the Runner loop 94 | member _.Dispatch(msg) = dispatch msg 95 | 96 | interface IDisposable with 97 | member _.Dispose() = stop() 98 | -------------------------------------------------------------------------------- /src/Fabulous/Sub.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | 5 | /// SubId - Subscription ID, alias for string list 6 | type SubId = string list 7 | 8 | /// Subscribe - Starts a subscription, returns IDisposable to stop it 9 | type Subscribe<'msg> = Dispatch<'msg> -> IDisposable 10 | 11 | /// Subscription - Generates new messages when running 12 | type Sub<'msg> = (SubId * Subscribe<'msg>) list 13 | 14 | module Sub = 15 | 16 | /// None - no subscriptions, also known as `[]` 17 | let none: Sub<'msg> = [] 18 | 19 | /// Aggregate multiple subscriptions 20 | let batch (subs: Sub<'msg> list) : Sub<'msg> = List.concat subs 21 | 22 | /// When emitting the message, map to another type. 23 | /// To avoid ID conflicts with other components, scope SubIds with a prefix. 24 | let map (idPrefix: string) (f: 'a -> 'msg) (sub: Sub<'a>) : Sub<'msg> = 25 | sub 26 | |> List.map(fun (subId, subscribe) -> idPrefix :: subId, (fun dispatch -> subscribe(f >> dispatch))) 27 | 28 | module Internal = 29 | 30 | module SubId = 31 | 32 | let toString (subId: SubId) = String.Join("/", subId) 33 | 34 | module Fx = 35 | 36 | let warnDupe onError subId = 37 | let ex = exn "Duplicate SubId" 38 | onError("Duplicate SubId: " + SubId.toString subId, ex) 39 | 40 | let tryStop onError (subId, sub: IDisposable) = 41 | try 42 | sub.Dispose() 43 | with ex -> 44 | onError("Error stopping subscription: " + SubId.toString subId, ex) 45 | 46 | let tryStart onError dispatch (subId, start) : (SubId * IDisposable) option = 47 | try 48 | Some(subId, start dispatch) 49 | with ex -> 50 | onError("Error starting subscription: " + SubId.toString subId, ex) 51 | None 52 | 53 | let stop onError subs = subs |> List.iter(tryStop onError) 54 | 55 | let change onError dispatch (dupes, toStop, toKeep, toStart) = 56 | dupes |> List.iter(warnDupe onError) 57 | toStop |> List.iter(tryStop onError) 58 | let started = toStart |> List.choose(tryStart onError dispatch) 59 | List.append toKeep started 60 | 61 | module NewSubs = 62 | 63 | let (_dupes, _newKeys, _newSubs) as init = List.empty, Set.empty, List.empty 64 | 65 | let update (subId, start) (dupes, newKeys, newSubs) = 66 | if Set.contains subId newKeys then 67 | subId :: dupes, newKeys, newSubs 68 | else 69 | dupes, Set.add subId newKeys, (subId, start) :: newSubs 70 | 71 | let calculate subs = List.foldBack update subs init 72 | 73 | let empty = List.empty 74 | 75 | let diff (activeSubs: (SubId * IDisposable) list) (sub: Sub<'msg>) = 76 | let keys = activeSubs |> List.map fst |> Set.ofList 77 | let dupes, newKeys, newSubs = NewSubs.calculate sub 78 | 79 | if keys = newKeys then 80 | dupes, [], activeSubs, [] 81 | else 82 | let toKeep, toStop = 83 | activeSubs |> List.partition(fun (k, _) -> Set.contains k newKeys) 84 | 85 | let toStart = newSubs |> List.filter(fun (k, _) -> not(Set.contains k keys)) 86 | dupes, toStop, toKeep, toStart 87 | -------------------------------------------------------------------------------- /src/Fabulous/Components/ComponentContext.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open System.ComponentModel 5 | 6 | (* 7 | ARCHITECTURE NOTES: 8 | 9 | Conceptually, a ComponentContext is an array containing all the current values for each state inside the component. 10 | Each state is associated with a index key that it can use to retrieve or update its value. 11 | 12 | The ComponentContext is meant to be attached to a Component instance, and passed implicitly in the body of the component 13 | where it will be accessible through let! bindings. 14 | 15 | Given each state is assigned to a specific index and that Components will most likely have a fixed number of bindings, 16 | we can leverage the inlining capabilities of the ComponentBuilder to create an array with the right size. 17 | *) 18 | 19 | /// 20 | /// Holds the values for the various states of a component. 21 | /// 22 | [] 23 | type ComponentContext(initialSize: int) = 24 | static let mutable nextId = 0 25 | 26 | static let getNextId () = 27 | nextId <- nextId + 1 28 | nextId 29 | 30 | let id = getNextId() 31 | let mutable values = Array.zeroCreate initialSize 32 | let disposables = System.Collections.Generic.Dictionary() 33 | 34 | let renderNeeded = Event() 35 | 36 | // We assume that most components will have few values, so initialize it with a small array 37 | new() = new ComponentContext(3) 38 | 39 | member this.Id = id 40 | 41 | member this.RenderNeeded = renderNeeded.Publish 42 | member this.NeedsRender() = renderNeeded.Trigger() 43 | 44 | member private this.ResizeIfNeeded(count: int) = 45 | // If the array is already big enough, we don't need to do anything 46 | // Otherwise, we create a new array and copy the values from the old one 47 | // It is assumed the component will have a stable amount of values, so this should not happen often 48 | if values.Length < count then 49 | let newLength = max (values.Length * 2) count 50 | let newArray = Array.zeroCreate newLength 51 | newArray[.. values.Length - 1] <- values 52 | values <- newArray 53 | 54 | member this.TryGetValue<'T>(key: int) = 55 | this.ResizeIfNeeded(key + 1) 56 | 57 | let value = values[key] 58 | 59 | if isNull value then 60 | ValueNone 61 | else 62 | ValueSome(unbox<'T> value) 63 | 64 | [] 65 | member this.SetValueInternal(key: int, value: 'T) = values[key] <- box value 66 | 67 | member this.SetValue(key: int, value: 'T) = 68 | this.SetValueInternal(key, value) 69 | this.NeedsRender() 70 | 71 | member this.LinkDisposable<'T when 'T :> IDisposable>(key: string, disposable: unit -> 'T) = 72 | if disposables.ContainsKey(key) then 73 | disposables[key] :?> 'T 74 | else 75 | let disposable = disposable() 76 | disposables[key] <- disposable 77 | disposable 78 | 79 | member this.Dispose() = 80 | for disposable in disposables do 81 | disposable.Value.Dispose() 82 | 83 | disposables.Clear() 84 | 85 | for value in values do 86 | if value :? IDisposable then 87 | (value :?> IDisposable).Dispose() 88 | 89 | values <- Array.empty 90 | 91 | interface IDisposable with 92 | member this.Dispose() = this.Dispose() 93 | 94 | [] 95 | type Context private () = class end 96 | -------------------------------------------------------------------------------- /src/Fabulous/Components/State.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System.ComponentModel 4 | open System.Runtime.CompilerServices 5 | 6 | type StateRequest<'T> = delegate of unit -> 'T 7 | 8 | /// DESIGN: StateValue<'T> is meant to be very short lived. 9 | /// It is created on Bind (let!) and destroyed at the end of a single ViewBuilder CE execution. 10 | /// Due to its nature, it is very likely it will be captured by a closure and allocated to the memory heap when it's not needed. 11 | /// 12 | /// e.g. 13 | /// 14 | /// Button("Increment", fun () -> stateValue.Set(stateValue.Current + 1)) 15 | /// 16 | /// will become 17 | /// 18 | /// class Closure { 19 | /// public StateValue stateValue; // Storing a struct on a class will allocate it on the heap 20 | /// 21 | /// public void Invoke() { 22 | /// stateValue.Set(stateValue.Current + 1); 23 | /// } 24 | /// } 25 | /// 26 | /// class Program { 27 | /// public void View() 28 | /// { 29 | /// var stateValue = new StateValue(...); 30 | /// 31 | /// // This will allocate both the closure and the stateValue on the heap 32 | /// // which the GC will have to clean up later 33 | /// var closure = new Closure(stateValue = stateValue); 34 | /// 35 | /// return Button("Increment", closure); 36 | /// } 37 | /// } 38 | /// 39 | /// 40 | /// The Set method is therefore marked inlinable to avoid creating a closure capturing StateValue<'T> 41 | /// Instead the closure will only capture Context (already a reference type), Key (int) and Current (can be consider to be obj). 42 | /// The compiler will rewrite the lambda as follow: 43 | /// 44 | /// Button("Increment", fun () -> ctx.SetValue(key, current + 1)) 45 | /// 46 | /// StateValue<'T> is no longer involved in the closure and will be kept on the stack. 47 | /// 48 | /// One constraint of inlining is to have all used fields public: Context, Key, Current 49 | /// But we don't wish to expose the Context and Key fields to the user, so we mark them as EditorBrowsable.Never 50 | [] 51 | type StateValue<'T> = 52 | [] 53 | val public Context: ComponentContext 54 | 55 | [] 56 | val public Key: int 57 | 58 | val public Current: 'T 59 | 60 | new(ctx, key, value) = 61 | { Context = ctx 62 | Key = key 63 | Current = value } 64 | 65 | member inline this.Set(value: 'T) = this.Context.SetValue(this.Key, value) 66 | 67 | [] 68 | module StateBuilders = 69 | type Context with 70 | 71 | static member inline State(defaultValue: 'T) = 72 | StateRequest<'T>(fun () -> defaultValue) 73 | 74 | [] 75 | type StateExtensions = 76 | [] 77 | static member inline Bind 78 | ( 79 | _: ComponentBuilder<'parentMsg, 'marker>, 80 | [] fn: StateRequest<'T>, 81 | [] continuation: StateValue<'T> -> ComponentBodyBuilder<'msg, 'marker> 82 | ) = 83 | ComponentBodyBuilder<'msg, 'marker>(fun envContext treeContext context bindings -> 84 | let key = int bindings 85 | 86 | let value = 87 | match context.TryGetValue<'T>(key) with 88 | | ValueSome value -> value 89 | | ValueNone -> 90 | let value = fn.Invoke() 91 | context.SetValue(key, value) 92 | value 93 | 94 | let stateValue = StateValue<'T>(context, key, value) 95 | 96 | (continuation stateValue).Invoke(envContext, treeContext, context, bindings + 1)) 97 | -------------------------------------------------------------------------------- /src/Fabulous/Memo.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open Fabulous.ScalarAttributeDefinitions 5 | 6 | module Memo = 7 | 8 | type internal MemoData = 9 | { 10 | /// Captures data that memoization depends on 11 | KeyData: obj 12 | 13 | // comparer that remembers KeyType internally 14 | KeyComparer: obj -> obj -> bool 15 | 16 | /// wrapped untyped lambda that users provide 17 | CreateWidget: obj -> Widget 18 | 19 | /// Captures type of data that memoization depends on 20 | KeyType: Type 21 | 22 | /// Captures type of the marker memoized function produces 23 | MarkerType: Type 24 | } 25 | 26 | type Memoized<'t> = { phantom: 't } 27 | 28 | let inline private compareAttributes (prev: MemoData) (next: MemoData) : ScalarAttributeComparison = 29 | match (prev.KeyType = next.KeyType, prev.MarkerType = next.MarkerType) with 30 | | true, true -> 31 | match next.KeyComparer next.KeyData prev.KeyData with 32 | | true -> ScalarAttributeComparison.Identical 33 | | false -> ScalarAttributeComparison.Different 34 | | _ -> ScalarAttributeComparison.Different 35 | 36 | let inline private updateNode _ (data: MemoData voption) (node: IViewNode) : unit = 37 | match data with 38 | | ValueSome memoData -> 39 | let memoizedWidget = memoData.CreateWidget memoData.KeyData 40 | 41 | let prevWidget = 42 | match node.MemoizedWidget with 43 | | Some widget -> ValueSome(widget) 44 | | _ -> ValueNone 45 | 46 | node.MemoizedWidget <- Some memoizedWidget 47 | 48 | Reconciler.update node.TreeContext.CanReuseView prevWidget memoizedWidget node 49 | 50 | | ValueNone -> () 51 | 52 | let private MemoAttributeKey = 53 | SimpleScalarAttributeDefinition.CreateAttributeData(compareAttributes, updateNode) 54 | |> AttributeDefinitionStore.registerScalar 55 | 56 | let inline private getMemoData (widget: Widget) : MemoData = 57 | match widget.ScalarAttributes with 58 | | [| attr |] -> attr.Value :?> MemoData 59 | | _ -> failwith "Memo widget cannot have extra attributes" 60 | 61 | let internal canReuseMemoizedWidget prev next = 62 | (getMemoData prev).MarkerType = (getMemoData next).MarkerType 63 | 64 | let internal MemoAttribute: SimpleScalarAttributeDefinition = 65 | { Key = MemoAttributeKey 66 | Name = "MemoAttribute" } 67 | 68 | let internal MemoWidgetKey = WidgetDefinitionStore.getNextKey() 69 | 70 | // Memo isn't allowed in lists, TargetType will never get called, 71 | // so Unchecked.defaultof is an acceptable value 72 | let private widgetDefinition: WidgetDefinition = 73 | { Key = MemoWidgetKey 74 | Name = "Memo" 75 | TargetType = Unchecked.defaultof<_> 76 | CreateView = 77 | fun (widget, treeContext, envContext, parentNode) -> 78 | 79 | let memoData = getMemoData widget 80 | 81 | let memoizedWidget = memoData.CreateWidget memoData.KeyData 82 | 83 | let memoizedDef = WidgetDefinitionStore.get memoizedWidget.Key 84 | 85 | let struct (node, view) = 86 | memoizedDef.CreateView(memoizedWidget, treeContext, envContext, parentNode) 87 | 88 | // store widget that was used to produce this node 89 | // to pass it to reconciler later on 90 | node.MemoizedWidget <- Some memoizedWidget 91 | struct (node, view) 92 | AttachView = fun (_widget, _treeContext, _envContext, _parentNode, _view) -> failwith "Memo widget cannot be attached" } 93 | 94 | WidgetDefinitionStore.set MemoWidgetKey widgetDefinition 95 | -------------------------------------------------------------------------------- /src/Fabulous/Components/Widget.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | 5 | module Component' = 6 | let Data = 7 | Attributes.defineSimpleScalar "Component_Data" ScalarAttributeComparers.noCompare (fun _ _ _ -> ()) 8 | 9 | let WidgetKey = 10 | let key = WidgetDefinitionStore.getNextKey() 11 | 12 | let definition = 13 | { Key = key 14 | Name = "Component" 15 | TargetType = typeof 16 | CreateView = 17 | fun (widget, envContext, treeContext, _) -> 18 | match widget.ScalarAttributes with 19 | | [||] -> failwith "Component widget must have a body" 20 | | attrs -> 21 | let data = 22 | let scalarAttrsOpt = 23 | attrs |> Array.tryFind(fun scalarAttr -> scalarAttr.Key = Data.Key) 24 | 25 | match scalarAttrsOpt with 26 | | Some attr -> attr.Value :?> ComponentData 27 | | None -> failwith "Component widget must have a body" 28 | 29 | let envContext = new EnvironmentContext(treeContext.Logger, envContext) 30 | let context = new ComponentContext() 31 | let comp = new Component(Data.Key, envContext, treeContext, context, data.Body) 32 | let struct (node, view) = comp.CreateView(ValueSome widget) 33 | 34 | treeContext.SetComponent comp view 35 | 36 | struct (node, view) 37 | AttachView = 38 | fun (widget, envContext, treeContext, _, view) -> 39 | match widget.ScalarAttributes with 40 | | [||] -> failwith "Component widget must have a body" 41 | | attrs -> 42 | let data = 43 | let scalarAttrsOpt = 44 | attrs |> Array.tryFind(fun scalarAttr -> scalarAttr.Key = Data.Key) 45 | 46 | match scalarAttrsOpt with 47 | | Some attr -> attr.Value :?> ComponentData 48 | | None -> failwith "Component widget must have a body" 49 | 50 | let envContext = new EnvironmentContext(treeContext.Logger, envContext) 51 | let context = new ComponentContext() 52 | let comp = new Component(Data.Key, envContext, treeContext, context, data.Body) 53 | let node = comp.AttachView(widget, view) 54 | 55 | treeContext.SetComponent comp view 56 | 57 | node } 58 | 59 | WidgetDefinitionStore.set key definition 60 | key 61 | 62 | let canReuseComponent (prev: Widget) (curr: Widget) = 63 | let prevData = 64 | match prev.ScalarAttributes with 65 | | [||] -> failwith "Component widget must have a body" 66 | | attrs -> 67 | let scalarAttrsOpt = 68 | attrs |> Array.tryFind(fun scalarAttr -> scalarAttr.Key = Data.Key) 69 | 70 | match scalarAttrsOpt with 71 | | None -> failwithf "Component widget must have a body" 72 | | Some value -> value.Value :?> ComponentData 73 | 74 | let currData = 75 | match curr.ScalarAttributes with 76 | | [||] -> failwith "Component widget must have a body" 77 | | attrs -> 78 | let scalarAttrsOpt = 79 | attrs |> Array.tryFind(fun scalarAttr -> scalarAttr.Key = Data.Key) 80 | 81 | match scalarAttrsOpt with 82 | | None -> failwithf "Component widget must have a body" 83 | | Some value -> value.Value :?> ComponentData 84 | 85 | // NOTE: Somehow using = here crashes the app and prevents debugging... 86 | Object.Equals(prevData.Key, currData.Key) 87 | -------------------------------------------------------------------------------- /src/Fabulous/EnvironmentContext.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open System.Collections.Generic 5 | 6 | [] 7 | type EnvironmentValueChanged(originEnvId: Guid, fromUserCode: bool, key: EnvironmentAttributeKey, value: obj voption) = 8 | member this.OriginEnvId = originEnvId 9 | member this.FromUserCode = fromUserCode 10 | member this.Key = key 11 | member this.Value = value 12 | 13 | [] 14 | type EnvironmentKey<'T>(key: string) = 15 | member this.Key = EnvironmentAttributeKey key 16 | 17 | and [] EnvironmentContext(logger: Logger, inheritedContext: EnvironmentContext) = 18 | let id = Guid.NewGuid() 19 | let values = Dictionary() 20 | let valueChanged = Event() 21 | 22 | do 23 | if inheritedContext = null then 24 | logger.Debug("EnvironmentContext '{0}' created", id) 25 | else 26 | logger.Debug("EnvironmentContext '{0}' created and inherited from '{1}'", id, inheritedContext.Id) 27 | 28 | let valuePropagationSubscription = 29 | if inheritedContext = null then 30 | null 31 | else 32 | inheritedContext.ValueChanged.Subscribe(fun args -> 33 | let (EnvironmentAttributeKey key) = args.Key 34 | logger.Debug("Env '{0}': Propagating '{1}' change from '{2}'", id, key, args.OriginEnvId) 35 | valueChanged.Trigger(args)) 36 | 37 | new(logger: Logger) = new EnvironmentContext(logger, null) 38 | 39 | member this.Id = id 40 | 41 | member private this.TryGetValue<'T>(key: EnvironmentAttributeKey) = 42 | if values.ContainsKey(key) then 43 | ValueSome(unbox<'T> values[key]) 44 | elif inheritedContext <> null then 45 | inheritedContext.TryGetValue(key) 46 | else 47 | ValueNone 48 | 49 | member internal this.SetInternal<'T>(key: EnvironmentAttributeKey, value: 'T, fromUserCode: bool) = 50 | let (EnvironmentAttributeKey envKey) = key 51 | logger.Debug("EnvironmentContext '{0}' set value '{1}' to '{2}'", id, envKey, value) 52 | let boxedValue = box value 53 | values[key] <- boxedValue 54 | valueChanged.Trigger(EnvironmentValueChanged(id, fromUserCode, key, ValueSome boxedValue)) 55 | 56 | member internal this.RemoveInternal(key: EnvironmentAttributeKey, fromUserCode: bool) = 57 | if values.Remove(key) then 58 | valueChanged.Trigger(EnvironmentValueChanged(id, fromUserCode, key, ValueNone)) 59 | 60 | member this.ValueChanged: IEvent = valueChanged.Publish 61 | 62 | member this.Get<'T>(key: EnvironmentKey<'T>) = 63 | match this.TryGetValue<'T>(key.Key) with 64 | | ValueSome value -> value 65 | | ValueNone -> 66 | let (EnvironmentAttributeKey key) = key.Key 67 | failwithf $"EnvironmentContext '{id}' does not contain value for key '{key}'" 68 | 69 | member this.Set<'T>(key: EnvironmentKey<'T>, value: 'T, ?fromUserCode: bool) = 70 | let fromUserCode = defaultArg fromUserCode true 71 | 72 | if values.ContainsKey(key.Key) || inheritedContext = null then 73 | let (EnvironmentAttributeKey envKey) = key.Key 74 | logger.Debug(envKey, "EnvironmentContext '{0}' set value '{1}' to '{2}'", id, key, value) 75 | let boxedValue = box value 76 | values[key.Key] <- boxedValue 77 | valueChanged.Trigger(EnvironmentValueChanged(id, fromUserCode, key.Key, ValueSome boxedValue)) 78 | else 79 | inheritedContext.Set<'T>(key, value, fromUserCode) 80 | 81 | interface IDisposable with 82 | member this.Dispose() = 83 | logger.Debug("EnvironmentContext '{0}' disposed", id) 84 | 85 | if valuePropagationSubscription <> null then 86 | valuePropagationSubscription.Dispose() 87 | 88 | for value in values.Values do 89 | if value :? IDisposable then 90 | (value :?> IDisposable).Dispose() 91 | 92 | values.Clear() 93 | -------------------------------------------------------------------------------- /src/Fabulous/Primitives.fs: -------------------------------------------------------------------------------- 1 | (* Dev notes: 2 | 3 | The types in this file will be the ones used the most internally by Fabulous. 4 | 5 | To enable the best performance possible, we want to avoid allocating them on 6 | the heap as must as possible (meaning they should be structs where possible) 7 | Also we want to avoid cache line misses, in that end, we make sure each struct 8 | can fit on a L1/L2 cache size by making those structs fit on 64 bits. 9 | 10 | Having those performance constraints prevents us for using inheritance 11 | or using interfaces on these structs *) 12 | 13 | namespace Fabulous 14 | 15 | /// Strongly types a scalar attribute key 16 | [] 17 | type scalarAttributeKey 18 | 19 | /// Strongly types a widget attribute key 20 | [] 21 | type widgetAttributeKey 22 | 23 | /// Strongly types a widget collection attribute key 24 | [] 25 | type widgetCollectionAttributeKey 26 | 27 | /// Key identifying a scalar attribute (e.g. Text, Image, etc.) 28 | type ScalarAttributeKey = int 29 | 30 | /// Key identifying a widget attribute (e.g. Content, etc.) 31 | type WidgetAttributeKey = int 32 | 33 | /// Key identifying a widget collection attribute (e.g. Children, Items, etc.) 34 | type WidgetCollectionAttributeKey = int 35 | 36 | /// Key identifying an environment attribute (e.g. Theme, etc.) 37 | type EnvironmentAttributeKey = EnvironmentAttributeKey of string 38 | 39 | module ScalarAttributeKey = 40 | [] 41 | type Kind = 42 | | Boxed // 1 43 | | Inline // 2 44 | 45 | module Code = 46 | [] 47 | // 1 <<< 30 48 | let Boxed = 1073741824 49 | 50 | [] 51 | // 2 <<< 30 52 | let Inline = -2147483648 53 | 54 | [] 55 | // 3 <<< 30 56 | let CodeMask = -1073741824 57 | 58 | [] 59 | // System.Int32.MaxValue >>> 2 60 | let KeyMask = 536870911 61 | 62 | let inline getKind (key: ScalarAttributeKey) : Kind = 63 | match (int key) &&& Code.Inline with 64 | | Code.Inline -> Inline 65 | | _ -> Boxed 66 | 67 | let inline getKeyValue (key: ScalarAttributeKey) : int = int key &&& Code.KeyMask 68 | 69 | let inline compare (a: ScalarAttributeKey) (b: ScalarAttributeKey) = 70 | let a = int a 71 | let b = int b 72 | a.CompareTo b 73 | 74 | module WidgetAttributeKey = 75 | let inline compare (a: WidgetAttributeKey) (b: WidgetAttributeKey) = 76 | let a = int a 77 | let b = int b 78 | a.CompareTo b 79 | 80 | module WidgetCollectionAttributeKey = 81 | let inline compare (a: WidgetCollectionAttributeKey) (b: WidgetCollectionAttributeKey) = 82 | let a = int a 83 | let b = int b 84 | a.CompareTo b 85 | 86 | module EnvironmentAttributeKey = 87 | let inline compare (EnvironmentAttributeKey a) (EnvironmentAttributeKey b) = a.CompareTo b 88 | 89 | type WidgetKey = int 90 | type StateKey = int 91 | type ViewAdapterKey = int 92 | 93 | /// Represents a value for a property of a widget 94 | [] 95 | type ScalarAttribute = 96 | { 97 | Key: ScalarAttributeKey 98 | #if DEBUG 99 | DebugName: string 100 | #endif 101 | /// Stores the value as object (boxed), prefer NumericValue when possible 102 | Value: obj 103 | /// Stores the value in a numeric form for faster performance (no boxing) 104 | NumericValue: uint64 105 | } 106 | 107 | /// Represents a single child of a widget 108 | and [] WidgetAttribute = 109 | { Key: WidgetAttributeKey 110 | #if DEBUG 111 | DebugName: string 112 | #endif 113 | Value: Widget } 114 | 115 | /// Represents a collection of children of a widget 116 | and [] WidgetCollectionAttribute = 117 | { Key: WidgetCollectionAttributeKey 118 | #if DEBUG 119 | DebugName: string 120 | #endif 121 | Value: ArraySlice } 122 | 123 | /// Represents an environment value of a widget 124 | and [] EnvironmentAttribute = 125 | { 126 | Key: EnvironmentAttributeKey 127 | #if DEBUG 128 | DebugName: string 129 | #endif 130 | /// Stores the value as object (boxed) 131 | Value: obj 132 | } 133 | 134 | /// Represents a virtual UI element such as a Label, a Button, etc. 135 | and [] Widget = 136 | { Key: WidgetKey 137 | #if DEBUG 138 | DebugName: string 139 | #endif 140 | ScalarAttributes: ScalarAttribute[] 141 | WidgetAttributes: WidgetAttribute[] 142 | WidgetCollectionAttributes: WidgetCollectionAttribute[] 143 | EnvironmentAttributes: EnvironmentAttribute[] } 144 | -------------------------------------------------------------------------------- /src/Fabulous.Tests/APISketchTests/TestUI.Platform.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests.APISketchTests 2 | 3 | module Platform = 4 | 5 | open System.Collections.Generic 6 | 7 | 8 | type TestViewElement() = 9 | member val AutomationId: string = "" with get, set 10 | member val PropertyBag = Dictionary() 11 | 12 | type IText = 13 | abstract member Text: string with get, set 14 | abstract member TextColor: string with get, set 15 | 16 | type ContainerHandler = unit -> unit 17 | 18 | type IContainer = 19 | abstract member Children: List 20 | abstract AddTapListener: ContainerHandler -> int 21 | abstract RemoveTapListener: int -> unit 22 | 23 | type ButtonHandler = unit -> unit 24 | 25 | type IButton = 26 | abstract AddPressListener: ButtonHandler -> int 27 | abstract RemovePressListener: int -> unit 28 | abstract AddTapListener: ButtonHandler -> int 29 | abstract RemoveTapListener: int -> unit 30 | abstract AddTap2Listener: ButtonHandler -> int 31 | abstract RemoveTap2Listener: int -> unit 32 | 33 | type LabelChangeList = 34 | | TextSet of string 35 | | ColorSet of string 36 | 37 | 38 | type TestLabel() = 39 | inherit TestViewElement() 40 | 41 | let mutable text = "" 42 | let mutable textColor = "" 43 | member val record = false with get, set 44 | 45 | member val changeList = [] with get, set 46 | 47 | interface IText with 48 | member x.Text 49 | with get () = text 50 | and set value = 51 | if x.record then 52 | x.changeList <- List.append x.changeList [ TextSet value ] 53 | 54 | text <- value 55 | 56 | member x.TextColor 57 | with get () = textColor 58 | and set value = 59 | if x.record then 60 | x.changeList <- List.append x.changeList [ ColorSet value ] 61 | 62 | textColor <- value 63 | 64 | type TestStack() = 65 | inherit TestViewElement() 66 | let mutable tapCounter: int = 1 67 | let tapHandlers = Dictionary() 68 | 69 | member _.Tap() = 70 | for handler in Array.ofSeq(tapHandlers.Values) do 71 | handler() 72 | 73 | interface IContainer with 74 | member val Children = List() 75 | 76 | member this.AddTapListener(handler) = 77 | tapHandlers.Add(tapCounter, handler) 78 | tapCounter <- tapCounter + 1 79 | tapCounter - 1 80 | 81 | member this.RemoveTapListener(id) = tapHandlers.Remove(id) |> ignore 82 | 83 | type TestButton() = 84 | inherit TestViewElement() 85 | let mutable pressCounter: int = 1 86 | let mutable tapCounter: int = 1 87 | let mutable tap2Counter: int = 1 88 | let pressHandlers = Dictionary() 89 | let tapHandlers = Dictionary() 90 | let tap2Handlers = Dictionary() 91 | 92 | member _.Press() = 93 | for handler in Array.ofSeq(pressHandlers.Values) do 94 | handler() 95 | 96 | member _.Tap() = 97 | for handler in Array.ofSeq(tapHandlers.Values) do 98 | handler() 99 | 100 | member _.Tap2() = 101 | for handler in Array.ofSeq(tap2Handlers.Values) do 102 | handler() 103 | 104 | interface IText with 105 | member val Text = "" with get, set 106 | member val TextColor = "" with get, set 107 | 108 | interface IButton with 109 | member this.AddPressListener(handler) = 110 | pressHandlers.Add(pressCounter, handler) 111 | pressCounter <- pressCounter + 1 112 | pressCounter - 1 113 | 114 | member this.RemovePressListener(id) = pressHandlers.Remove(id) |> ignore 115 | 116 | member this.AddTapListener(handler) = 117 | tapHandlers.Add(tapCounter, handler) 118 | tapCounter <- tapCounter + 1 119 | tapCounter - 1 120 | 121 | member this.RemoveTapListener(id) = tapHandlers.Remove(id) |> ignore 122 | 123 | member this.AddTap2Listener(handler) = 124 | tap2Handlers.Add(tap2Counter, handler) 125 | tap2Counter <- tap2Counter + 1 126 | tap2Counter - 1 127 | 128 | member this.RemoveTap2Listener(id) = tap2Handlers.Remove(id) |> ignore 129 | 130 | 131 | type TestNumericBag() = 132 | inherit TestViewElement() 133 | member val valueOne = 0UL with get, set 134 | member val valueTwo = 0UL with get, set 135 | member val valueThree = 0.0 with get, set 136 | -------------------------------------------------------------------------------- /src/Fabulous/Program.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open System.Diagnostics 5 | 6 | /// Configuration of the Fabulous application 7 | type Program<'arg, 'model, 'msg> = 8 | { 9 | /// Give the initial state for the application 10 | Init: 'arg -> 'model * Cmd<'msg> 11 | /// Update the application state based on a message 12 | Update: 'msg * 'model -> 'model * Cmd<'msg> 13 | /// Add a subscription that can dispatch messages 14 | Subscribe: 'model -> Sub<'msg> 15 | /// Configuration for logging all output messages from Fabulous 16 | Logger: Logger 17 | /// Exception handler for all uncaught exceptions happening in the MVU loop. 18 | /// Returns true if the exception was handled, false otherwise. 19 | ExceptionHandler: exn -> bool 20 | } 21 | 22 | type Program<'arg, 'model, 'msg, 'marker when 'msg: equality> = 23 | { 24 | State: Program<'arg, 'model, 'msg> 25 | /// Render the application state 26 | View: 'model -> WidgetBuilder<'msg, 'marker> 27 | /// Indicates if a previous Widget's view can be reused 28 | CanReuseView: Widget -> Widget -> bool 29 | /// Runs the View function on the main thread 30 | SyncAction: (unit -> unit) -> unit 31 | } 32 | 33 | module ProgramDefaults = 34 | let defaultLogger () = 35 | let log (level, message) = 36 | let traceLevel = 37 | match level with 38 | | LogLevel.Debug -> "Debug" 39 | | LogLevel.Info -> "Information" 40 | | LogLevel.Warn -> "Warning" 41 | | LogLevel.Error -> "Error" 42 | | _ -> "Error" 43 | 44 | Trace.WriteLine(message, traceLevel) 45 | 46 | { Log = log 47 | MinLogLevel = LogLevel.Error } 48 | 49 | let defaultExceptionHandler exn = 50 | Trace.WriteLine(String.Format("Unhandled exception: {0}", exn.ToString()), "Debug") 51 | false 52 | 53 | module Program = 54 | let inline private define (init: 'arg -> 'model * Cmd<'msg>) (update: 'msg -> 'model -> 'model * Cmd<'msg>) = 55 | { Init = init 56 | Update = (fun (msg, model) -> update msg model) 57 | Subscribe = fun _ -> Sub.none 58 | Logger = ProgramDefaults.defaultLogger() 59 | ExceptionHandler = ProgramDefaults.defaultExceptionHandler } 60 | 61 | /// Create a program using an MVU loop 62 | let stateful (init: 'arg -> 'model) (update: 'msg -> 'model -> 'model) = 63 | define (fun arg -> init arg, Cmd.none) (fun msg model -> update msg model, Cmd.none) 64 | 65 | /// Create a program using an MVU loop 66 | let statefulWithCmd (init: 'arg -> 'model * Cmd<'msg>) (update: 'msg -> 'model -> 'model * Cmd<'msg>) = define init update 67 | 68 | /// Create a program using an MVU loop. Add support for CmdMsg 69 | let statefulWithCmdMsg (init: 'arg -> 'model * 'cmdMsg list) (update: 'msg -> 'model -> 'model * 'cmdMsg list) (mapCmd: 'cmdMsg -> Cmd<'msg>) = 70 | let mapCmds cmdMsgs = cmdMsgs |> List.map mapCmd |> Cmd.batch 71 | define (fun arg -> let m, c = init arg in m, mapCmds c) (fun msg model -> let m, c = update msg model in m, mapCmds c) 72 | 73 | /// Subscribe to external source of events, overrides existing subscription. 74 | /// Return the subscriptions that should be active based on the current model. 75 | /// Subscriptions will be started or stopped automatically to match. 76 | let withSubscription (subscribe: 'model -> Sub<'msg>) (program: Program<'arg, 'model, 'msg>) = { program with Subscribe = subscribe } 77 | 78 | /// Map existing subscription to external source of events. 79 | let mapSubscription map (program: Program<'arg, 'model, 'msg>) = 80 | { program with 81 | Subscribe = map program.Subscribe } 82 | 83 | /// Configure how the output messages from Fabulous will be handled 84 | let withLogger (logger: Logger) (program: Program<'arg, 'model, 'msg>) = { program with Logger = logger } 85 | 86 | /// Trace all the updates to the debug output 87 | let withTrace (trace: string * string -> unit) (program: Program<'arg, 'model, 'msg>) = 88 | let traceInit arg = 89 | try 90 | let initModel, cmd = program.Init(arg) 91 | trace("Initial model: {0}", $"%0A{initModel}") 92 | initModel, cmd 93 | with e -> 94 | trace("Error in init function: {0}", $"%0A{e}") 95 | reraise() 96 | 97 | let traceUpdate (msg, model) = 98 | trace("Message: {0}", $"%0A{msg}") 99 | 100 | try 101 | let newModel, cmd = program.Update(msg, model) 102 | trace("Updated model: {0}", $"%0A{newModel}") 103 | newModel, cmd 104 | with e -> 105 | trace("Error in model function: {0}", $"%0A{e}") 106 | reraise() 107 | 108 | { program with 109 | Init = traceInit 110 | Update = traceUpdate } 111 | 112 | /// Configure how the unhandled exceptions happening during the execution of a Fabulous app with be handled 113 | let withExceptionHandler (handler: exn -> bool) (program: Program<'arg, 'model, 'msg>) = 114 | { program with 115 | ExceptionHandler = handler } 116 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | maintainers@fabulous.dev. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | **Note:** If these contribution guidelines are not followed your issue or PR might be closed, so 4 | please read these instructions carefully. 5 | 6 | 7 | ## Contribution types 8 | 9 | 10 | ### Bug Reports 11 | 12 | - If you find a bug, please first report it using [Github issues]. 13 | - First check if there is not already an issue for it; duplicated issues will be closed. 14 | 15 | 16 | ### Bug Fix 17 | 18 | - If you'd like to submit a fix for a bug, please read the [How To](#how-to-contribute) for how to 19 | send a Pull Request. 20 | - Indicate on the open issue that you are working on fixing the bug and the issue will be assigned 21 | to you. 22 | - Write `Fixes #xxxx` in your PR text, where xxxx is the issue number (if there is one). 23 | - Include a test that isolates the bug and verifies that it was fixed. 24 | 25 | 26 | ### New Features 27 | 28 | - If you'd like to add a feature to the library that doesn't already exist, feel free to describe 29 | the feature in a new [GitHub issue]. 30 | - You can also join us on [Discord] to discuss some initials thoughts. 31 | - If you'd like to implement the new feature, please wait for feedback from the project maintainers 32 | before spending too much time writing the code. In some cases, enhancements may not align well 33 | with the project future development direction. 34 | - Implement the code for the new feature and please read the [How To](#how-to-contribute). 35 | 36 | 37 | ### Documentation & Miscellaneous 38 | 39 | - If you have suggestions for improvements to the documentation, tutorial or examples (or something 40 | else), we would love to hear about it. 41 | - As always first file a [Github issue]. 42 | - Implement the changes to the documentation, please read the [How To](#how-to-contribute). 43 | 44 | 45 | ## How To Contribute 46 | 47 | 48 | ### Requirements 49 | 50 | For a contribution to be accepted: 51 | 52 | - Format the code using 53 | ``` 54 | dotnet tool restore 55 | dotnet fantomas -r src 56 | ``` 57 | - Check that all tests pass: `dotnet test`; 58 | - Documentation should always be updated or added (if applicable); 59 | - Examples should always be updated or added (if applicable); 60 | - Tests should always be updated or added (if applicable). 61 | 62 | If the contribution doesn't meet these criteria, a maintainer will discuss it with you on the issue 63 | or PR. You can still continue to add more commits to the branch you have sent the Pull Request from 64 | and it will be automatically reflected in the PR. 65 | 66 | 67 | ## Open an issue and fork the repository 68 | 69 | - If it is a bigger change or a new feature, first of all 70 | [file a bug or feature report][GitHub issue], so that we can discuss what direction to follow. 71 | - [Fork the project][fork guide] on GitHub. 72 | - Clone the forked repository to your local development machine 73 | (e.g. `git clone git@github.com:/Fabulous.git`). 74 | 75 | 76 | ### Environment Setup 77 | 78 | Setting up your environment for Fabulous is pretty easy. 79 | You will only need to install the [.NET 7.0 SDK] matching your CPU architecture (x64 or Arm64 for Mac M1). 80 | 81 | After .NET is installed, you can make sure Fabulous builds by executing the command line `dotnet build` at the Fabulous root folder. 82 | 83 | You can also pick any IDE you prefer to work on the codebase: Visual Studio (both Windows and macOS), Jetbrains Rider, or Visual Studio Code (with the [Ionide plugin]). 84 | 85 | ### Performing changes 86 | 87 | - Create a new local branch from `main` (e.g. `git checkout -b my-new-feature`) 88 | - Make your changes (try to split them up with one PR per feature/fix). 89 | - When committing your changes, make sure that each commit message is clear. 90 | - Push your new branch to your own fork into the same remote branch 91 | (e.g. `git push origin my-username.my-new-feature`, replace `origin` if you use another remote.) 92 | 93 | 94 | ### Open a pull request 95 | 96 | Go to the [pull request page of Fabulous][PRs] and in the top 97 | of the page it will ask you if you want to open a pull request from your newly created branch. 98 | 99 | The title of the pull request should be descriptive of the work you did. 100 | 101 | 102 | ## Maintainers 103 | 104 | These instructions are for the maintainers of Fabulous. 105 | 106 | 107 | ### Merging a pull request 108 | 109 | When merging a pull request, make sure that the title of the merge commit has a descriptive title. 110 | 111 | 112 | ### Creating a release 113 | 114 | There are a few things to think about when doing a release: 115 | 116 | - Search through the codebase for `[]` methods/fields and remove the ones that are marked 117 | for removal in the version that you are intending to release. 118 | - Create a PR containing the changes for removing the deprecated entities. 119 | - Update [CHANGELOG.md] with the latest fixes and features since the last release. 120 | - Go through the PRs with breaking changes and add migration documentation to the changelog. 121 | There should be migration docs on each PR, if they haven't been copied to the commit message. 122 | - Bump the version number in the [build workflow] file so Github can release nightly packages with the new version. 123 | - Make sure the build pipeline is succeeding on the main branch. 124 | - Once you are satisfied, create a new release via the [Github releases page], create a new tag with the version you want (eg. `2.2.0`), use the version number as the release name, paste the [CHANGELOG.md] section about this release into the description, and hit Submit. 125 | - The release pipeline will need to be approved by one of the maintainers with release rights. 126 | 127 | 128 | [GitHub issue]: https://github.com/fabulous-dev/fabulous/issues 129 | [GitHub issues]: https://github.com/fabulous-dev/fabulous/issues 130 | [GitHub releases page]: https://github.com/fabulous-dev/Fabulous/releases/new 131 | [PRs]: https://github.com/fabulous-dev/fabulous/pulls 132 | [fork guide]: https://docs.github.com/en/get-started/quickstart/contributing-to-projects 133 | [Discord]: https://discord.gg/bpTJMbSSYK 134 | [.NET 7.0 SDK]: https://dotnet.microsoft.com/en-us/download 135 | [Ionide plugin]: https://ionide.io/Editors/Code/overview.html 136 | [build workflow]: .github/workflows/build.yml 137 | [CHANGELOG.md]: CHANGELOG.md -------------------------------------------------------------------------------- /src/Fabulous.Benchmarks/Benchmarks.fs: -------------------------------------------------------------------------------- 1 | module Fabulous.Tests.Benchmarks 2 | 3 | open BenchmarkDotNet.Attributes 4 | 5 | open BenchmarkDotNet.Running 6 | open Fabulous.Tests.APISketchTests.TestUI_Widgets 7 | 8 | open type View 9 | 10 | 11 | module NestedTreeCreation = 12 | [] 13 | type Model = { depth: int } 14 | 15 | [] 16 | type Msg = Depth of int 17 | 18 | let rec viewInner (depth: int) = 19 | // printfn $"view on {depth}" 20 | 21 | Stack() { 22 | if (depth > 0) then 23 | viewInner(depth - 1) 24 | 25 | Label($"label1:{depth}").textColor("red").automationId($"label1:{depth}") 26 | 27 | Label($"label2:{depth}").textColor("green").automationId($"label2:{depth}") 28 | 29 | Button($"btn: {depth}", Depth depth) 30 | 31 | if (depth > 0) then 32 | viewInner(depth - 2) 33 | } 34 | 35 | let view d = viewInner d 36 | 37 | // [] 38 | [] 39 | [] 40 | // [] 41 | // [] 42 | type Benchmarks() = 43 | [] 44 | member val depth = 0 with get, set 45 | 46 | [] 47 | member x.CreateWidgets() = view x.depth 48 | 49 | 50 | module DiffingAttributes = 51 | // [] 52 | type Model = { depth: int; counter: int } 53 | 54 | // [] 55 | type Msg = IncBy of int 56 | 57 | let update msg model = 58 | match msg with 59 | | IncBy amount -> 60 | { model with 61 | counter = model.counter + amount } 62 | 63 | let rec viewInner depth counter = 64 | Stack() { 65 | Label($"label1:{counter} {depth}").textColor("red").automationId($"label1:{depth}") 66 | 67 | Label($"label2:{counter} {depth}").textColor("green").automationId($"label2:{depth}") 68 | 69 | Button($"btn: {depth}", IncBy 2) 70 | 71 | if (depth > 0) then 72 | viewInner (depth - 1) counter 73 | 74 | if (depth > 0) then 75 | viewInner (depth - 2) counter 76 | } 77 | 78 | let view model = viewInner model.depth model.counter 79 | 80 | [] 81 | [] 82 | type Benchmarks() = 83 | [] 84 | member val depth = 0 with get, set 85 | 86 | [] 87 | member x.ProcessMessages() = 88 | let program = 89 | StatefulWidget.mkSimpleView (fun () -> { depth = x.depth; counter = 0 }) update view 90 | 91 | let instance = Run.Instance program 92 | 93 | let _tree = instance.Start() 94 | 95 | for i in 1..100 do 96 | instance.ProcessMessage(IncBy i) 97 | 98 | 99 | module DiffingSmallScalars = 100 | [] 101 | type Model = { depth: int; counter: uint64 } 102 | 103 | [] 104 | type Msg = IncBy of uint64 105 | 106 | let update msg model = 107 | match msg with 108 | | IncBy amount -> 109 | { model with 110 | counter = model.counter + amount } 111 | 112 | let rec viewBoxedInner depth counter = 113 | // this is to emulate changing value only once per 5 updates 114 | let value = counter / 2UL 115 | 116 | Stack() { 117 | BoxedNumericBag(value, value, float value) 118 | BoxedNumericBag(value, value, float value) 119 | BoxedNumericBag(value, value, float value) 120 | 121 | if (depth > 0) then 122 | viewBoxedInner (depth - 1) counter 123 | 124 | if (depth > 0) then 125 | viewBoxedInner (depth - 2) counter 126 | } 127 | 128 | let rec viewInlineInner depth counter = 129 | // this is to emulate changing value only once per 5 updates 130 | let value = counter / 2UL 131 | 132 | Stack() { 133 | InlineNumericBag(value, value, float value) 134 | InlineNumericBag(value, value, float value) 135 | InlineNumericBag(value, value, float value) 136 | 137 | if (depth > 0) then 138 | viewInlineInner (depth - 1) counter 139 | 140 | if (depth > 0) then 141 | viewInlineInner (depth - 2) counter 142 | } 143 | 144 | let viewBoxed model = 145 | viewBoxedInner model.depth model.counter 146 | 147 | let viewInline model = 148 | viewInlineInner model.depth model.counter 149 | 150 | [] 151 | [] 152 | type Benchmarks() = 153 | [] 154 | member val depth = 0 with get, set 155 | 156 | [] 157 | member val boxed = true with get, set 158 | 159 | [] 160 | member x.ProcessIncrements() = 161 | let program = 162 | 163 | let view = if x.boxed then viewBoxed else viewInline 164 | 165 | StatefulWidget.mkSimpleView (fun () -> { depth = x.depth; counter = 0UL }) update view 166 | 167 | let instance = Run.Instance program 168 | 169 | let _tree = instance.Start() 170 | 171 | for i in 1..100 do 172 | instance.ProcessMessage(IncBy 1UL) 173 | 174 | 175 | 176 | [] 177 | let main _argv = 178 | // BenchmarkRunner.Run() 179 | // |> ignore 180 | // 181 | // BenchmarkRunner.Run() 182 | // |> ignore 183 | 184 | printfn "Hello" 185 | 186 | BenchmarkRunner.Run(typeof.Assembly) |> ignore 187 | 188 | 0 // return an integer exit code 189 | 190 | //[] 191 | //let mainProfile argv = 192 | // 193 | // let b = NestedTreeCreation.Benchmarks() 194 | // b.depth <- 25 195 | // 196 | // for _ in 0 .. 10 do 197 | // let widgets = b.CreateWidgets() 198 | // () 199 | // 200 | // 0 // return an integer exit code 201 | 202 | //[] 203 | //let mainProfile argv = 204 | // 205 | // let b = DiffingAttributes.Benchmarks() 206 | // b.depth <- 10 207 | // 208 | // for _ in 0 .. 2 do 209 | // let widgets = b.ProcessMessages() 210 | // () 211 | // 212 | // 0 // return an integer exit code 213 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | 5 | Fabulous 6 | 7 |

8 |
9 | 10 | [![build](https://img.shields.io/github/actions/workflow/status/fabulous-dev/Fabulous/build.yml?branch=v2.1)](https://github.com/fabulous-dev/Fabulous/actions/workflows/build.yml) [![NuGet version](https://img.shields.io/nuget/v/Fabulous)](https://www.nuget.org/packages/Fabulous) [![NuGet downloads](https://img.shields.io/nuget/dt/Fabulous)](https://www.nuget.org/packages/Fabulous) [![Discord](https://img.shields.io/discord/716980335593914419?label=discord&logo=discord)](https://discord.gg/bpTJMbSSYK) [![Twitter Follow](https://img.shields.io/twitter/follow/FabulousAppDev?style=social)](https://twitter.com/FabulousAppDev) 11 | 12 | Fabulous is a modern declarative UI framework for crafting cross-platform mobile and desktop apps in .NET. 13 | It aims to bring you a great development experience and confidence in your code by combining an expressive UI syntax, the simple & robust Model-View-Update (MVU) architecture, and functional programming. 14 | 15 | ## Documentation 16 | 17 | The full documentation for Fabulous can be found at [docs.fabulous.dev](https://docs.fabulous.dev). 18 | 19 | Other useful links: 20 | - [The official Fabulous website](https://fabulous.dev) 21 | - [Get started](https://docs.fabulous.dev/get-started) 22 | - [API Reference](https://api.fabulous.dev) 23 | - [Contributor Guide](CONTRIBUTING.md) 24 | 25 | Additionally, we have the [Fabulous Discord server](https://discord.gg/bpTJMbSSYK) where you can ask any of your Fabulous related questions. 26 | 27 | ## About Fabulous 28 | 29 | We believe declarative UI, functional programming, and the MVU state management are a perfect fit for app development. 30 | 31 | Fabulous will help you create mobile and desktop apps quickly and with confidence thanks to declarative UI and the [MVU](https://zaid-ajaj.github.io/the-elmish-book/#/chapters/elm/) architecture, all in one single language: [F#](https://fsharp.org) - a functional programing language. 32 | 33 | Fabulous also aims to be performant by having low memory consumption and efficient view diffing mechanisms. 34 | 35 | Note that Fabulous itself does not provide any UI rendering. You'll need to combine it with another framework like: 36 | - [.NET MAUI](https://dotnet.microsoft.com/en-us/apps/maui) with [Fabulous.MauiControls](https://github.com/fabulous-dev/Fabulous.MauiControls) 37 | - [AvaloniaUI](https://avaloniaui.net) with [Fabulous.Avalonia](https://github.com/fabulous-dev/Fabulous.Avalonia) 38 | 39 | ### Declarative UI 40 | 41 | Typical UI development can be a nightmare if not done properly. 42 | It is generally created in one place, then mutated here and there based on the need and what the user is doing. Related UI pieces end up in several places, making it hard to mentally think of all the possibilities; until the inevitable race condition or bug due to an unintended user flow. 43 | 44 | Fabulous makes it easier to reason about UI thanks to its declarative UI inspired by SwiftUI. 45 | The UI of a component is defined in a single place and Fabulous will call it everytime the state of that component is changed. 46 | 47 | You don't need to think about how to mutate the UI, Fabulous will handle it for you to always match the latest UI you need. 48 | 49 | ```fs 50 | /// A simple Counter app made with Fabulous.MauiControls 51 | type Model = 52 | { Count: int } 53 | 54 | type Msg = 55 | | Increment 56 | | Decrement 57 | 58 | let view model = 59 | Application( 60 | ContentPage( 61 | "Counter app", 62 | VStack(spacing = 16.) { 63 | Image(Aspect.AspectFit, "fabulous.png") 64 | 65 | Label($"Count is {model.Count}") 66 | 67 | Button("Increment", Increment) 68 | Button("Decrement", Decrement) 69 | } 70 | ) 71 | ) 72 | ``` 73 | 74 | ### MVU architecture 75 | 76 | MVU makes every state and transition between those states explicit. 77 | You don't need to worry about unintended actions that could lead to an invalid state which would crash the app. 78 | 79 | Instead, you can very easily model the state of your app or component and transitions between them using F# records and discriminated unions types. 80 | When starting, Fabulous will initialize the state. Then, when messages are being dispatched, Fabulous will let you transition from one state to the other given a specific message. 81 | 82 | If several messages are received at the same time, Fabulous will queue them to let you update the state properly. 83 | 84 | ```fs 85 | let init () = 86 | { Count = 0 } 87 | 88 | let update msg model = 89 | match msg with 90 | | Increment -> { model with Count = model.Count + 1 } 91 | | Decrement -> { model with Count = model.Count - 1 } 92 | ``` 93 | 94 | And finally, given the functional nature of MVU, it is extremely simple to unit test each and every possible state of your application. 95 | 96 | ```fs 97 | [] 98 | let ``When clicking the Increment button, increment the count by one``() = 99 | let previousState = { Count = 10 } 100 | let expectedState = { Count = 11 } 101 | 102 | let actualState = App.update Increment previousState 103 | 104 | actualState |> should equal expectedState 105 | ``` 106 | 107 | ### Powered by .NET 108 | 109 | .NET is a very mature and broad framework by Microsoft. It can run on any device and platform, is very efficient, and has a vast ecosystem of open-source and licensed libraries, plugins, and other frameworks. 110 | You will be able to benefit from the .NET ecosystem by using 3rd party packages directly in your Fabulous application. 111 | 112 | ## Supporting this project 113 | 114 | The simplest way to show us your support is by giving the project a star. 115 | 116 | You can also support us by becoming our sponsor on the GitHub Sponsors program. 117 | This is a fantastic way to support all the efforts going into making Fabulous the best declarative UI framework for dotnet. 118 | 119 | If you need support see Commercial Support section below. 120 | 121 | ## Contributing 122 | 123 | Have you found a bug or have a suggestion of how to enhance Fabulous? Open an issue and we will take a look at it as soon as possible. 124 | 125 | Do you want to contribute with a PR? PRs are always welcome, just make sure to create it from the correct branch (main) and follow the [Contributor Guide](CONTRIBUTING.md). 126 | 127 | For bigger changes, or if in doubt, make sure to talk about your contribution to the team. Either via an issue, GitHub discussion, or reach out to the team either using the [Discord server](https://discord.gg/bpTJMbSSYK). 128 | 129 | ## Commercial support 130 | 131 | If you would like us to provide you with: 132 | 133 | - training and workshops, 134 | - support services, 135 | - and consulting services. 136 | 137 | Feel free to contact us: [support@fabulous.dev](mailto:support@fabulous.dev) 138 | 139 | ## Star History 140 | 141 | 142 | 143 | 144 | 145 | Star History Chart 146 | 147 | -------------------------------------------------------------------------------- /src/Fabulous.Tests/APISketchTests/TestUI.Attributes.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests.APISketchTests 2 | 3 | module TestUI_Attributes = 4 | 5 | open System 6 | open Fabulous 7 | open Fabulous.ScalarAttributeDefinitions 8 | open Platform 9 | 10 | module Attributes = 11 | 12 | let definePressable name : ScalarAttributeDefinition = 13 | let key = 14 | ScalarAttributeDefinition.CreateAttributeData( 15 | (fun x -> x), 16 | ScalarAttributeComparers.noCompare, 17 | (fun _ newValueOpt node -> 18 | 19 | let btn = node.Target :?> IButton 20 | 21 | match node.TryGetHandler(name) with 22 | | ValueNone -> () 23 | | ValueSome handler -> handler.Dispose() 24 | 25 | match newValueOpt with 26 | | ValueNone -> node.RemoveHandler(name) 27 | 28 | | ValueSome msg -> 29 | let handler () = Dispatcher.dispatch node msg 30 | let handlerId = btn.AddPressListener handler 31 | 32 | let disposable = 33 | { new IDisposable with 34 | member _.Dispose() = btn.RemovePressListener handlerId } 35 | 36 | node.SetHandler(name, disposable)) 37 | ) 38 | |> AttributeDefinitionStore.registerScalar 39 | 40 | { Key = key; Name = name } 41 | 42 | let defineTappable name : ScalarAttributeDefinition = 43 | let key = 44 | ScalarAttributeDefinition.CreateAttributeData( 45 | (fun x -> x), 46 | ScalarAttributeComparers.noCompare, 47 | (fun _ newValueOpt node -> 48 | 49 | let btn = node.Target :?> IButton 50 | 51 | match node.TryGetHandler(name) with 52 | | ValueNone -> () 53 | | ValueSome handler -> handler.Dispose() 54 | 55 | match newValueOpt with 56 | | ValueNone -> node.RemoveHandler(name) 57 | 58 | | ValueSome msg -> 59 | let handler () = Dispatcher.dispatch node msg 60 | let handlerId = btn.AddTapListener handler 61 | 62 | let disposable = 63 | { new IDisposable with 64 | member _.Dispose() = btn.RemoveTapListener handlerId } 65 | 66 | node.SetHandler(name, disposable)) 67 | ) 68 | |> AttributeDefinitionStore.registerScalar 69 | 70 | { Key = key; Name = name } 71 | 72 | let defineTappable2 name : ScalarAttributeDefinition = 73 | let key = 74 | ScalarAttributeDefinition.CreateAttributeData( 75 | (fun x -> x), 76 | ScalarAttributeComparers.noCompare, 77 | (fun _ newValueOpt node -> 78 | 79 | let btn = node.Target :?> IButton 80 | 81 | match node.TryGetHandler(name) with 82 | | ValueNone -> () 83 | | ValueSome handler -> handler.Dispose() 84 | 85 | match newValueOpt with 86 | | ValueNone -> node.RemoveHandler(name) 87 | 88 | | ValueSome msg -> 89 | let handler () = Dispatcher.dispatch node msg 90 | let handlerId = btn.AddTap2Listener handler 91 | 92 | let disposable = 93 | { new IDisposable with 94 | member _.Dispose() = btn.RemoveTap2Listener handlerId } 95 | 96 | node.SetHandler(name, disposable)) 97 | ) 98 | |> AttributeDefinitionStore.registerScalar 99 | 100 | { Key = key; Name = name } 101 | 102 | let defineContainerTappable name : ScalarAttributeDefinition = 103 | let key = 104 | ScalarAttributeDefinition.CreateAttributeData( 105 | (fun x -> x), 106 | ScalarAttributeComparers.noCompare, 107 | (fun _ newValueOpt node -> 108 | 109 | let btn = node.Target :?> IContainer 110 | 111 | match node.TryGetHandler(name) with 112 | | ValueNone -> () 113 | | ValueSome handler -> handler.Dispose() 114 | 115 | match newValueOpt with 116 | | ValueNone -> node.RemoveHandler(name) 117 | 118 | | ValueSome msg -> 119 | let handler () = Dispatcher.dispatch node msg 120 | let handlerId = btn.AddTapListener handler 121 | 122 | let disposable = 123 | { new IDisposable with 124 | member _.Dispose() = btn.RemoveTapListener handlerId } 125 | 126 | node.SetHandler(name, disposable)) 127 | ) 128 | |> AttributeDefinitionStore.registerScalar 129 | 130 | { Key = key; Name = name } 131 | 132 | 133 | 134 | 135 | // --------------- Actual Properties --------------- 136 | // open Fabulous.Attributes 137 | 138 | module Text = 139 | let Record = Attributes.defineBool "Record" TestUI_ViewUpdaters.updateRecord 140 | 141 | let Text = 142 | Attributes.defineSimpleScalarWithEquality "Text" TestUI_ViewUpdaters.updateText 143 | 144 | 145 | module TextStyle = 146 | let TextColor = 147 | Attributes.defineSimpleScalarWithEquality "TextColor" TestUI_ViewUpdaters.updateTextColor 148 | 149 | module Container = 150 | let Children = 151 | Attributes.defineListWidgetCollection "Container_Children" (fun target -> 152 | (target :?> IContainer).Children :> System.Collections.Generic.IList<_>) 153 | 154 | let Tap = defineContainerTappable "Container_Tap" 155 | 156 | module Button = 157 | let Pressed = definePressable "Button_Pressed" 158 | let Tap = defineTappable "Button_Tap" 159 | let Tap2 = defineTappable2 "Button_Tap2" 160 | 161 | 162 | module Automation = 163 | let AutomationId = 164 | Attributes.defineSimpleScalarWithEquality "AutomationId" TestUI_ViewUpdaters.updateAutomationId 165 | 166 | module NumericBag = 167 | let InlineValueOne = 168 | Attributes.defineSmallScalar "InlineValueOne" id TestUI_ViewUpdaters.updateNumericValueOne 169 | 170 | let InlineValueTwo = 171 | Attributes.defineSmallScalar "InlineValueTwo" id TestUI_ViewUpdaters.updateNumericValueTwo 172 | 173 | let InlineValueThree = 174 | Attributes.defineSmallScalar "InlineValueThree" BitConverter.UInt64BitsToDouble TestUI_ViewUpdaters.updateNumericValueThree 175 | 176 | 177 | let BoxedValueOne = 178 | Attributes.defineSimpleScalarWithEquality "BoxedValueOne" TestUI_ViewUpdaters.updateNumericValueOne 179 | 180 | let BoxedValueTwo = 181 | Attributes.defineSimpleScalarWithEquality "BoxedValueTwo" TestUI_ViewUpdaters.updateNumericValueTwo 182 | 183 | let BoxedValueThree = 184 | Attributes.defineSimpleScalarWithEquality "BoxedValueThree" TestUI_ViewUpdaters.updateNumericValueThree 185 | -------------------------------------------------------------------------------- /src/Fabulous/Components/Component.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | 5 | /// This measure type is used to count the number of bindings in a component while building the computation expression 6 | [] 7 | type binding 8 | 9 | type ComponentBody = 10 | delegate of EnvironmentContext * ViewTreeContext * ComponentContext -> struct (EnvironmentContext * ViewTreeContext * ComponentContext * Widget) 11 | 12 | [] 13 | type ComponentData = { Key: string; Body: ComponentBody } 14 | 15 | type Component 16 | (componentDataKey: ScalarAttributeKey, envContext: EnvironmentContext, treeContext: ViewTreeContext, context: ComponentContext, body: ComponentBody) = 17 | let mutable _envContext = envContext 18 | let mutable _treeContext = treeContext 19 | let mutable _context = context 20 | let mutable _body = body 21 | let mutable _widget = Unchecked.defaultof<_> 22 | let mutable _view = null 23 | let mutable _contextSubscription: IDisposable = null 24 | 25 | let mutable _isReadyForRenderRequest = false 26 | let mutable _pendingRenderRequested = false 27 | 28 | member private this.MergeAttributes(rootWidget: Widget, componentWidgetOpt: Widget voption) = 29 | match componentWidgetOpt with 30 | | ValueNone -> 31 | struct (rootWidget.ScalarAttributes, rootWidget.WidgetAttributes, rootWidget.WidgetCollectionAttributes, rootWidget.EnvironmentAttributes) 32 | 33 | | ValueSome componentWidget -> 34 | let componentScalars = 35 | match componentWidget.ScalarAttributes with 36 | | [||] -> [||] 37 | | attrs -> 38 | let filteredAttrs = 39 | attrs |> Array.filter(fun scalarAttr -> scalarAttr.Key <> componentDataKey) 40 | 41 | filteredAttrs // skip the component data 42 | 43 | let scalars = 44 | match rootWidget.ScalarAttributes, componentScalars with 45 | | [||], [||] -> [||] 46 | | attrs, [||] 47 | | [||], attrs -> attrs 48 | | widgetAttrs, componentAttrs -> Array.append componentAttrs widgetAttrs 49 | 50 | let widgets = 51 | match rootWidget.WidgetAttributes, componentWidget.WidgetAttributes with 52 | | [||], [||] -> [||] 53 | | attrs, [||] 54 | | [||], attrs -> attrs 55 | | widgetAttrs, componentAttrs -> Array.append componentAttrs widgetAttrs 56 | 57 | let widgetColls = 58 | match rootWidget.WidgetCollectionAttributes, componentWidget.WidgetCollectionAttributes with 59 | | [||], [||] -> [||] 60 | | attrs, [||] 61 | | [||], attrs -> attrs 62 | | widgetAttrs, componentAttrs -> Array.append componentAttrs widgetAttrs 63 | 64 | let environments = 65 | match rootWidget.EnvironmentAttributes, componentWidget.EnvironmentAttributes with 66 | | [||], [||] -> [||] 67 | | attrs, [||] 68 | | [||], attrs -> attrs 69 | | widgetAttrs, componentAttrs -> Array.append componentAttrs widgetAttrs 70 | 71 | struct (scalars, widgets, widgetColls, environments) 72 | 73 | member this.CreateView(componentWidget: Widget voption) = 74 | _isReadyForRenderRequest <- false 75 | _contextSubscription <- _context.RenderNeeded.Subscribe(this.Render) 76 | 77 | let struct (envContext, treeContext, context, rootWidget) = 78 | _body.Invoke(_envContext, _treeContext, _context) 79 | 80 | _widget <- rootWidget 81 | _envContext <- envContext 82 | _treeContext <- treeContext 83 | _context <- context 84 | 85 | let struct (scalars, widgets, widgetColls, environments) = 86 | this.MergeAttributes(rootWidget, componentWidget) 87 | 88 | let rootWidget: Widget = 89 | { Key = rootWidget.Key 90 | #if DEBUG 91 | DebugName = rootWidget.DebugName 92 | #endif 93 | ScalarAttributes = scalars 94 | WidgetAttributes = widgets 95 | WidgetCollectionAttributes = widgetColls 96 | EnvironmentAttributes = environments } 97 | 98 | // Create the actual view 99 | let widgetDef = WidgetDefinitionStore.get rootWidget.Key 100 | 101 | let struct (node, view) = 102 | widgetDef.CreateView(rootWidget, envContext, treeContext, ValueNone) 103 | 104 | _view <- view 105 | _isReadyForRenderRequest <- true 106 | 107 | // ComponentContext.SetNeedsRender has been called before the view is created 108 | // We need to re-render the component now because the state has changed before we were ready 109 | if _pendingRenderRequested then 110 | _pendingRenderRequested <- false 111 | this.Render() 112 | 113 | struct (node, view) 114 | 115 | member this.AttachView(componentWidget: Widget, view: obj) = 116 | _isReadyForRenderRequest <- false 117 | _contextSubscription <- _context.RenderNeeded.Subscribe(this.Render) 118 | 119 | let struct (envContext, treeContext, context, rootWidget) = 120 | _body.Invoke(_envContext, _treeContext, _context) 121 | 122 | _widget <- rootWidget 123 | _envContext <- envContext 124 | _treeContext <- treeContext 125 | _context <- context 126 | 127 | let struct (scalars, widgets, widgetColls, environments) = 128 | this.MergeAttributes(rootWidget, ValueSome componentWidget) 129 | 130 | let rootWidget: Widget = 131 | { Key = rootWidget.Key 132 | #if DEBUG 133 | DebugName = rootWidget.DebugName 134 | #endif 135 | ScalarAttributes = scalars 136 | WidgetAttributes = widgets 137 | WidgetCollectionAttributes = widgetColls 138 | EnvironmentAttributes = environments } 139 | 140 | // Attach the widget to the existing view 141 | let widgetDef = WidgetDefinitionStore.get rootWidget.Key 142 | 143 | let node = 144 | widgetDef.AttachView(rootWidget, envContext, treeContext, ValueNone, view) 145 | 146 | _view <- view 147 | _isReadyForRenderRequest <- true 148 | 149 | // ComponentContext.SetNeedsRender has been called before the view is created 150 | // We need to re-render the component now because the state has changed before we were ready 151 | if _pendingRenderRequested then 152 | _pendingRenderRequested <- false 153 | this.Render() 154 | 155 | node 156 | 157 | member private this.RenderInternal() = 158 | if isNull _body then 159 | () // Component has been disposed 160 | else 161 | let prevRootWidget = _widget 162 | let prevContext = _context 163 | 164 | let struct (envContext, treeContext, context, currRootWidget) = 165 | _body.Invoke(_envContext, _treeContext, _context) 166 | 167 | _widget <- currRootWidget 168 | _envContext <- envContext 169 | _treeContext <- treeContext 170 | 171 | if prevContext <> context then 172 | _contextSubscription.Dispose() 173 | prevContext.Dispose() 174 | _contextSubscription <- context.RenderNeeded.Subscribe(this.Render) 175 | _context <- context 176 | 177 | let viewNode = treeContext.GetViewNode _view 178 | 179 | Reconciler.update treeContext.CanReuseView (ValueSome prevRootWidget) currRootWidget viewNode 180 | 181 | member this.Dispose() = 182 | if not(isNull _contextSubscription) then 183 | _contextSubscription.Dispose() 184 | 185 | if not(isNull _context) then 186 | _context.Dispose() 187 | 188 | _body <- null 189 | _widget <- Unchecked.defaultof<_> 190 | _view <- null 191 | _envContext <- Unchecked.defaultof<_> 192 | _treeContext <- Unchecked.defaultof<_> 193 | _contextSubscription <- null 194 | _context <- null 195 | 196 | interface IDisposable with 197 | member this.Dispose() = this.Dispose() 198 | 199 | member this.Render() = 200 | if isNull _body then 201 | () // Component has been disposed 202 | else if not _isReadyForRenderRequest then 203 | _pendingRenderRequested <- true 204 | else 205 | treeContext.SyncAction(this.RenderInternal) 206 | -------------------------------------------------------------------------------- /src/Fabulous/ViewNode.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System 4 | open System.Collections.Generic 5 | open Fabulous 6 | 7 | /// Define the logic to apply diffs and store event handlers of its target control 8 | [] 9 | type ViewNode = 10 | val mutable parent: IViewNode option 11 | val mutable envContext: EnvironmentContext 12 | val mutable treeContext: ViewTreeContext 13 | val mutable targetRef: WeakReference 14 | val mutable isDisposed: bool 15 | val mutable memoizedWidget: Widget option 16 | val mutable mapMsg: (obj -> obj) option 17 | 18 | // TODO consider combine handlers mapMsg and property bag 19 | // also we can probably use just Dictionary instead of Map because 20 | // ViewNode is supposed to be mutable, stateful and persistent object 21 | val handlers: Dictionary 22 | 23 | new(parent: IViewNode option, envContext: EnvironmentContext, treeContext: ViewTreeContext, target: WeakReference) = 24 | { parent = parent 25 | envContext = envContext 26 | treeContext = treeContext 27 | targetRef = target 28 | handlers = Dictionary() 29 | isDisposed = false 30 | memoizedWidget = None 31 | mapMsg = None } 32 | 33 | member inline private this.ApplyScalarDiffs(diffs: ScalarChanges inref) : unit = 34 | let node = this :> IViewNode 35 | 36 | for diff in diffs do 37 | match diff with 38 | | ScalarChange.Added added -> 39 | let key = added.Key 40 | 41 | match ScalarAttributeKey.getKind key with 42 | | ScalarAttributeKey.Inline -> 43 | let smallScalar = (AttributeDefinitionStore.getSmallScalar key) 44 | 45 | smallScalar.UpdateNode ValueNone (ValueSome added.NumericValue) node 46 | 47 | | ScalarAttributeKey.Boxed -> 48 | let scalar = (AttributeDefinitionStore.getScalar key) 49 | 50 | scalar.UpdateNode ValueNone (ValueSome added.Value) node 51 | 52 | | ScalarChange.Removed removed -> 53 | 54 | let key = removed.Key 55 | 56 | match ScalarAttributeKey.getKind key with 57 | | ScalarAttributeKey.Inline -> 58 | let smallScalar = (AttributeDefinitionStore.getSmallScalar key) 59 | 60 | smallScalar.UpdateNode (ValueSome removed.NumericValue) ValueNone node 61 | 62 | | ScalarAttributeKey.Boxed -> 63 | let scalar = (AttributeDefinitionStore.getScalar key) 64 | 65 | scalar.UpdateNode (ValueSome removed.Value) ValueNone node 66 | 67 | | ScalarChange.Updated(oldAttr, newAttr) -> 68 | let key = oldAttr.Key 69 | 70 | match ScalarAttributeKey.getKind key with 71 | | ScalarAttributeKey.Inline -> 72 | let smallScalar = (AttributeDefinitionStore.getSmallScalar key) 73 | 74 | smallScalar.UpdateNode (ValueSome oldAttr.NumericValue) (ValueSome newAttr.NumericValue) node 75 | 76 | | ScalarAttributeKey.Boxed -> 77 | let scalar = (AttributeDefinitionStore.getScalar key) 78 | 79 | scalar.UpdateNode (ValueSome oldAttr.Value) (ValueSome newAttr.Value) node 80 | 81 | member inline private this.ApplyWidgetDiffs(diffs: WidgetChanges inref) = 82 | for diff in diffs do 83 | match diff with 84 | | WidgetChange.Added newWidget -> 85 | let definition = (AttributeDefinitionStore.getWidget newWidget.Key) 86 | 87 | definition.UpdateNode ValueNone (ValueSome newWidget.Value) (this :> IViewNode) 88 | 89 | | WidgetChange.ReplacedBy(oldWidget, newWidget) -> 90 | let definition = (AttributeDefinitionStore.getWidget newWidget.Key) 91 | 92 | definition.UpdateNode (ValueSome oldWidget.Value) (ValueSome newWidget.Value) (this :> IViewNode) 93 | 94 | | WidgetChange.Removed removed -> 95 | let definition = (AttributeDefinitionStore.getWidget removed.Key) 96 | 97 | definition.UpdateNode (ValueSome removed.Value) ValueNone (this :> IViewNode) 98 | 99 | | WidgetChange.Updated(newAttr, diffs) -> 100 | let definition = (AttributeDefinitionStore.getWidget newAttr.Key) 101 | 102 | definition.ApplyDiff diffs (this :> IViewNode) 103 | 104 | member inline private this.ApplyWidgetCollectionDiffs(diffs: WidgetCollectionChanges inref) = 105 | for diff in diffs do 106 | match diff with 107 | | WidgetCollectionChange.Added added -> 108 | let definition = (AttributeDefinitionStore.getWidgetCollection added.Key) 109 | 110 | definition.UpdateNode ValueNone (ValueSome added.Value) (this :> IViewNode) 111 | 112 | | WidgetCollectionChange.Removed removed -> 113 | let definition = (AttributeDefinitionStore.getWidgetCollection removed.Key) 114 | 115 | definition.UpdateNode (ValueSome removed.Value) ValueNone (this :> IViewNode) 116 | 117 | | WidgetCollectionChange.Updated(oldAttr, newAttr, diffs) -> 118 | let definition = (AttributeDefinitionStore.getWidgetCollection newAttr.Key) 119 | 120 | definition.ApplyDiff oldAttr.Value diffs (this :> IViewNode) 121 | 122 | member inline private this.ApplyEnvironmentDiffs(diffs: EnvironmentChanges inref) : unit = 123 | let node = this :> IViewNode 124 | 125 | for diff in diffs do 126 | match diff with 127 | | EnvironmentChange.Added added -> 128 | let key = added.Key 129 | node.EnvironmentContext.SetInternal(key, added.Value, true) 130 | 131 | | EnvironmentChange.Removed removed -> 132 | let key = removed.Key 133 | node.EnvironmentContext.RemoveInternal(key, true) 134 | 135 | | EnvironmentChange.Updated(oldAttr, newAttr) -> 136 | let key = oldAttr.Key 137 | node.EnvironmentContext.SetInternal(key, newAttr.Value, true) 138 | 139 | interface IViewNode with 140 | member this.Target = this.targetRef.Target 141 | member this.TreeContext = this.treeContext 142 | member this.EnvironmentContext = this.envContext 143 | 144 | member this.MemoizedWidget 145 | with get () = this.memoizedWidget 146 | and set value = this.memoizedWidget <- value 147 | 148 | member this.Parent = this.parent 149 | 150 | member this.MapMsg 151 | with get () = this.mapMsg 152 | and set value = this.mapMsg <- value 153 | 154 | member this.IsDisconnected = this.isDisposed 155 | 156 | member this.TryGetHandler(key: string) = 157 | match this.handlers.TryGetValue(key) with 158 | | false, _ -> ValueNone 159 | | true, handler -> ValueSome(handler) 160 | 161 | member this.SetHandler(key: string, handler: IDisposable) = this.handlers[key] <- handler 162 | 163 | member this.RemoveHandler(key: string) = 164 | if this.handlers.ContainsKey(key) then 165 | let handler = this.handlers[key] 166 | this.handlers.Remove(key) |> ignore 167 | handler.Dispose() 168 | 169 | member this.Dispose() = 170 | this.isDisposed <- true 171 | 172 | // Dispose all the event handlers of this node 173 | for kvp in this.handlers do 174 | kvp.Value.Dispose() 175 | 176 | this.handlers.Clear() 177 | 178 | // Dispose the attached Component if any 179 | if this.targetRef.IsAlive then 180 | let comp = this.treeContext.GetComponent(this.targetRef.Target) :?> IDisposable 181 | 182 | if not(isNull comp) then 183 | comp.Dispose() 184 | this.treeContext.SetComponent null this.targetRef.Target 185 | 186 | this.parent <- None 187 | this.treeContext <- Unchecked.defaultof<_> 188 | this.targetRef <- null 189 | 190 | member this.ApplyDiff(diff) = 191 | if not this.targetRef.IsAlive then 192 | () 193 | else 194 | this.ApplyEnvironmentDiffs(&diff.EnvironmentChanges) 195 | this.ApplyWidgetDiffs(&diff.WidgetChanges) 196 | this.ApplyWidgetCollectionDiffs(&diff.WidgetCollectionChanges) 197 | this.ApplyScalarDiffs(&diff.ScalarChanges) 198 | -------------------------------------------------------------------------------- /src/Fabulous/Components/README.md: -------------------------------------------------------------------------------- 1 | ## What's going on here: 2 | This is an attempt at making re-executable computation expressions with a context being passed implicitly. 3 | 4 | ## History and constraints 5 | Today in Fabulous, there is only one source of truth for the whole app: it's root state. 6 | 7 | Whenever a change happens in this root state, the whole view hierarchy is re-evaluated to check for any 8 | UI update that needs to be applied on the screen. Having this single source of truth is great to ensure consistency, 9 | but it implies a lot of unnecessary processing because 99% of the time a state change will only have an impact locally, 10 | not globally, hence it would be better to only re-evaluate the local view hierarchy. 11 | 12 | This idea is known as "components": you can see them as some kind of mini-apps managing their own local state 13 | that can trigger re-evaluation on their own and that can be composed together to make an actual Fabulous application. 14 | 15 | Despite quite a lot of prior arts (SwiftUI "View" protocol, React components, FuncUI components, Vide builders, etc.), 16 | it has been difficult to come up with a component approach in Fabulous due to the unique set of constraints: mobile & F#. 17 | While the implementation is straightforward in the other F# libraries (FuncUI, Vide), they make heavy use of closures 18 | which allocate of lot of memory; something Fabulous cannot afford because GC would keep freezing the app 19 | on lower end Android smartphones due to limited memory. Hence it is better to avoid closures and make heavy use 20 | of structs instead of classes. 21 | 22 | Also another aspect why it has been difficult to come up with anything is the opinionated ergonomics wanted for Fabulous. 23 | Fabulous took a similar approach to SwiftUI: a builder pattern with handcrafted widgets and modifiers. 24 | But contrary to Swift, in .NET (C# & F#) using interfaces (protocols in Swift) over struct will result in boxing because 25 | a struct first need to be transformed into an object before being casted to the interface. This triggers a lot of memory 26 | allocation, which is what we want to avoid in the first place with the structs, so a different approach is required. 27 | 28 | type IComponent = interface end 29 | type [] TextWidget(value: string) = interface IComponent 30 | 31 | let text = TextWidget("Hello") 32 | let component = text :> IComponent // ----> let component = text >> box :> IComponent 33 | 34 | Another point we want to take a look into is the ability to use any kind of state management, not only MVU. 35 | 36 | With all those constraints in place, we want something that can easily be composed into Fabulous 2 DSL ergonomics, 37 | lets you choose your own state management, and almost allocation-free to be friendly with low end mobile devices. 38 | 39 | This means we need to make heavy use of inlining and structs. 40 | Computation expressions to the rescue. 41 | 42 | ## Implementation ideas 43 | 44 | A component needs to somehow hold its own state and have a view description that can be evaluated at will everytime 45 | the state changes. 46 | 47 | let component = 48 | view { 49 | let! count = state 0 50 | 51 | VStack() { 52 | Text($"Count is {count.Value}") 53 | Button("Increment", fun () -> count.Set(count + 1)) 54 | Button("Decrement", fun () -> count.Set(count - 1)) 55 | } 56 | } 57 | 58 | To achieve this, we can create a ViewBuilder computation expression that will store its body into a function. 59 | The state is bound to variables by using `let!`. 60 | 61 | 62 | builder.Run( 63 | builder.Delay( 64 | builder.Bind(state 0, fun count -> // this is for "let! count = state 0" 65 | builder.Yield( // this is an implicit yield 66 | VStack() { ... } 67 | ) 68 | ) 69 | ) 70 | ) 71 | 72 | The ViewBuilder makes use of the implicit yield capability of F# by implementing: "Yield", "Combine", and "Delay". 73 | Contrary to what the F# documentation states, "Zero" is not required to have implicit yield. 74 | 75 | - Yield: Widget -> Contextual 76 | - Combine: [] Contextual * [] Contextual -> Contextual 77 | - Delay: [] (unit -> Contextual) -> Contextual 78 | 79 | Contextual is a composable delegate that take a Context (so we can pass it implicitly around the CE, mainly to be used 80 | in "Bind" without making it visible in the user code) and return a Widget, which is the typical body of a component. 81 | 82 | Why are we using a delegate here? 83 | Delegates are basically lambdas, so combining this with inlined CE methods ("member inline Yield", etc.) and the attribute 84 | [], we can flatten the whole body of the CE into a single Contextual lambda. 85 | 86 | Example: 87 | 88 | let result = 89 | (fun () -> // the Delay 90 | Contextual(fun ctx -> // the Bind 91 | let count = ctx.GetState(0) 92 | Contextual(fun ctx -> // the Yield 93 | VStack() { 94 | Text($"Count is {count.Value}") 95 | } 96 | ) 97 | ) 98 | )() 99 | 100 | will become 101 | 102 | let result = 103 | Contextual(fun ctx -> 104 | let count = ctx.GetState(0) 105 | VStack() { 106 | Text($"Count is {count.Value}") 107 | } 108 | ) 109 | 110 | 111 | Since we already get a "Contextual" at every step of the CE, "Run" doesn't need any specific implementation except 112 | returning the latest Contextual function. 113 | 114 | ## How does state works and how everything gets re-evaluated on change 115 | 116 | "let! count = state 0" is a request to the implicit context passed around in the CE to retrieve the previous state value 117 | or initialize it with the default value "0" 118 | 119 | inline state 0 // helper function to hide the default factory lambda 120 | --> StateRequest(fun () -> 0) // StateRequest is also an inlinable delegate 121 | --> let! === ctx.TryGetValue() or ctx.SetValue(0) 122 | --> struct State(ctx, key, value) 123 | 124 | - static member inline Bind(_: ViewBuilder, [] request: StateRequest<'T>, [] continuation: State<'T> -> Contextual) 125 | 126 | Since we are passing the Context itself into the State struct value given to the user, when the user calls "count.Set(newValue)", 127 | it will mark the context as dirty, meaning a re-evaluation is needed. 128 | 129 | This context is originated from the Component that hold both its own Context and the Contextual lambda created by the CE. 130 | The Component listens to its context Dirtied event to know when to re-evaluate the body. 131 | 132 | Another important point is, the context uses positional indexes to store and retrieve the states. 133 | 134 | Say you have the following body: 135 | 136 | let body = 137 | view { 138 | let! firstName = state "George" 139 | let! lastName = state "Roger" 140 | (...) 141 | } 142 | 143 | behind the scene, when calling Bind for firstName, Context will switch to the index 0. 144 | Then when calling Bind for lastName, Context will switch to index 1. 145 | Resulting in 146 | 147 | Context.values = 148 | [0] = "George" 149 | [1] = "Roger" 150 | 151 | On subsequent reevaluations, Context switch back to index 0 to retrieve the values in order 152 | let firstName = Context.values[0] 153 | let lastName = Context.values[1] 154 | 155 | This means conditional state is to be avoided 156 | 157 | DONT: 158 | let body = 159 | view { 160 | if someCondition then 161 | let! firstName = state "George" 162 | Text("First name is {firstName.Value}") 163 | else 164 | let! lastName = state "Roger" 165 | Text("Last name is {lastName.Value}") 166 | } 167 | 168 | This will returning a confusing result. 169 | 170 | // 1st execution - someCondition = true 171 | First name is George 172 | 173 | // 2nd execution - someCondition = false 174 | Last name is George 175 | 176 | As a user, you expect to have two independent states: one for FirstName, one for LastName. 177 | But since Context uses positional access to retrieve the values, firstName and lastName make no difference for Context 178 | since they will both have Position = 0. 179 | 180 | 181 | ############### 182 | 183 | A nice thing about this approach is that we can share a context between several components. 184 | This is useful is the context of controls repeated several times that actually represent a same thing 185 | (eg the avatar picture in the chat message page that gets repeated in front of each message) 186 | 187 | let sharedContext = Context() 188 | 189 | let avatar1 = Component(sharedContext, Avatar()) 190 | let avatar2 = Component(sharedContext, Avatar()) 191 | 192 | avatar1.Background <- Blue 193 | // Automatically triggers avatar2.Background to become Blue -------------------------------------------------------------------------------- /src/Fabulous/AttributeDefinitions.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | module ScalarAttributeDefinitions = 4 | /// A small scalar attribute. 5 | /// When we can encode the value as a uint64 (64 bits), we should prefer this type. 6 | /// The value will be kept on the stack avoiding GC pressure. 7 | [] 8 | type SmallScalarAttributeData = 9 | { UpdateNode: uint64 voption -> uint64 voption -> IViewNode -> unit } 10 | 11 | /// A regular scalar attribute. 12 | /// The value will be boxed and put on the heap, which can trigger GC to pass. 13 | /// Prefer small scalar attribute when possible. 14 | [] 15 | type ScalarAttributeData = 16 | { UpdateNode: obj voption -> obj voption -> IViewNode -> unit 17 | CompareBoxed: obj -> obj -> ScalarAttributeComparison } 18 | 19 | /// Attribute definition for small scalar properties (encodable on 64 bits) 20 | [] 21 | type SmallScalarAttributeDefinition<'T> = 22 | { Key: ScalarAttributeKey 23 | Name: string } 24 | 25 | member inline x.WithValue(value: 'T, [] encode: 'T -> uint64) : ScalarAttribute = 26 | { Key = x.Key 27 | #if DEBUG 28 | DebugName = x.Name 29 | #endif 30 | NumericValue = encode(value) 31 | Value = null } 32 | 33 | static member inline CreateAttributeData<'T> 34 | ([] decode: uint64 -> 'T, [] updateNode: 'T voption -> 'T voption -> IViewNode -> unit) 35 | : SmallScalarAttributeData = 36 | { UpdateNode = 37 | (fun oldValueOpt newValueOpt node -> 38 | let oldValueOpt = 39 | match oldValueOpt with 40 | | ValueNone -> ValueNone 41 | | ValueSome v -> ValueSome(decode(v)) 42 | 43 | let newValueOpt = 44 | match newValueOpt with 45 | | ValueNone -> ValueNone 46 | | ValueSome v -> ValueSome(decode(v)) 47 | 48 | updateNode oldValueOpt newValueOpt node) } 49 | 50 | /// Attribute definition for boxed scalar properties 51 | [] 52 | type SimpleScalarAttributeDefinition<'T> = 53 | { Key: ScalarAttributeKey 54 | Name: string } 55 | 56 | member inline x.WithValue(value: 'T) : ScalarAttribute = 57 | { Key = x.Key 58 | #if DEBUG 59 | DebugName = x.Name 60 | #endif 61 | NumericValue = 0UL 62 | Value = value } 63 | 64 | static member CreateAttributeData 65 | (compare: 'T -> 'T -> ScalarAttributeComparison, updateNode: 'T voption -> 'T voption -> IViewNode -> unit) 66 | : ScalarAttributeData = 67 | { CompareBoxed = (fun a b -> compare (unbox<'T> a) (unbox<'T> b)) 68 | UpdateNode = 69 | (fun oldValueOpt newValueOpt node -> 70 | let oldValueOpt = 71 | match oldValueOpt with 72 | | ValueNone -> ValueNone 73 | | ValueSome v -> ValueSome(unbox<'T> v) 74 | 75 | let newValueOpt = 76 | match newValueOpt with 77 | | ValueNone -> ValueNone 78 | | ValueSome v -> ValueSome(unbox<'T> v) 79 | 80 | updateNode oldValueOpt newValueOpt node) } 81 | 82 | /// Attribute definition for boxed scalar properties with a custom conversion before being applied to the view 83 | [] 84 | type ScalarAttributeDefinition<'modelType, 'valueType> = 85 | { Key: ScalarAttributeKey 86 | Name: string } 87 | 88 | member inline x.WithValue(value: 'modelType) : ScalarAttribute = 89 | { Key = x.Key 90 | #if DEBUG 91 | DebugName = x.Name 92 | #endif 93 | NumericValue = 0UL 94 | Value = value } 95 | 96 | static member inline CreateAttributeData<'modelType, 'valueType> 97 | ( 98 | [] convertValue: 'modelType -> 'valueType, 99 | [] compare: 'modelType -> 'modelType -> ScalarAttributeComparison, 100 | [] updateNode: 'valueType voption -> 'valueType voption -> IViewNode -> unit 101 | ) : ScalarAttributeData = 102 | { CompareBoxed = (fun a b -> compare (unbox<'modelType> a) (unbox<'modelType> b)) 103 | UpdateNode = 104 | (fun oldValueOpt newValueOpt node -> 105 | let oldValueOpt = 106 | match oldValueOpt with 107 | | ValueNone -> ValueNone 108 | | ValueSome v -> ValueSome(convertValue(unbox<'modelType> v)) 109 | 110 | let newValueOpt = 111 | match newValueOpt with 112 | | ValueNone -> ValueNone 113 | | ValueSome v -> ValueSome(convertValue(unbox<'modelType> v)) 114 | 115 | updateNode oldValueOpt newValueOpt node) } 116 | 117 | module WidgetAttributeDefinitions = 118 | [] 119 | type WidgetAttributeData = 120 | { ApplyDiff: WidgetDiff -> IViewNode -> unit 121 | UpdateNode: Widget voption -> Widget voption -> IViewNode -> unit } 122 | 123 | /// Attribute definition for widget properties 124 | [] 125 | type WidgetAttributeDefinition = 126 | { Key: WidgetAttributeKey 127 | Name: string } 128 | 129 | member inline x.WithValue(value: Widget) : WidgetAttribute = 130 | { Key = x.Key 131 | #if DEBUG 132 | DebugName = x.Name 133 | #endif 134 | Value = value } 135 | 136 | module WidgetCollectionAttributeDefinitions = 137 | [] 138 | type WidgetCollectionAttributeData = 139 | { ApplyDiff: ArraySlice -> WidgetCollectionItemChanges -> IViewNode -> unit 140 | UpdateNode: ArraySlice voption -> ArraySlice voption -> IViewNode -> unit } 141 | 142 | /// Attribute definition for collection properties 143 | [] 144 | type WidgetCollectionAttributeDefinition = 145 | { Key: WidgetCollectionAttributeKey 146 | Name: string } 147 | 148 | member inline x.WithValue(value: ArraySlice) : WidgetCollectionAttribute = 149 | { Key = x.Key 150 | #if DEBUG 151 | DebugName = x.Name 152 | #endif 153 | Value = value } 154 | 155 | module AttributeDefinitionStore = 156 | open ScalarAttributeDefinitions 157 | open WidgetAttributeDefinitions 158 | open WidgetCollectionAttributeDefinitions 159 | 160 | let private _scalars = ResizeArray() 161 | let private _smallScalars = ResizeArray() 162 | let private _widgets = ResizeArray() 163 | let private _widgetCollections = ResizeArray() 164 | 165 | let registerSmallScalar (data: SmallScalarAttributeData) : ScalarAttributeKey = 166 | let index = _smallScalars.Count 167 | _smallScalars.Add(data) 168 | 169 | (index ||| ScalarAttributeKey.Code.Inline) * 1 170 | 171 | let registerScalar (data: ScalarAttributeData) : ScalarAttributeKey = 172 | let index = _scalars.Count 173 | _scalars.Add(data) 174 | 175 | (index ||| ScalarAttributeKey.Code.Boxed) * 1 176 | 177 | let registerWidget (data: WidgetAttributeData) : WidgetAttributeKey = 178 | let index = _widgets.Count 179 | _widgets.Add(data) 180 | index * 1 181 | 182 | let registerWidgetCollection (data: WidgetCollectionAttributeData) : WidgetCollectionAttributeKey = 183 | let index = _widgetCollections.Count 184 | _widgetCollections.Add(data) 185 | index * 1 186 | 187 | let getScalar (key: ScalarAttributeKey) : ScalarAttributeData = 188 | let index = ScalarAttributeKey.getKeyValue key 189 | _scalars[index] 190 | 191 | let getSmallScalar (key: ScalarAttributeKey) : SmallScalarAttributeData = 192 | let index = ScalarAttributeKey.getKeyValue key 193 | _smallScalars[index] 194 | 195 | let getWidget (key: WidgetAttributeKey) : WidgetAttributeData = _widgets[int key] 196 | 197 | let getWidgetCollection (key: WidgetCollectionAttributeKey) : WidgetCollectionAttributeData = _widgetCollections[int key] 198 | 199 | module AttributeHelpers = 200 | open ScalarAttributeDefinitions 201 | 202 | let tryFindSimpleScalarAttribute (definition: SimpleScalarAttributeDefinition<'T>) (widget: Widget) = 203 | match widget.ScalarAttributes |> Array.tryFind(fun attr -> attr.Key = definition.Key) with 204 | | None -> ValueNone 205 | | Some attr -> ValueSome(unbox<'T> attr.Value) 206 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # Tye 66 | .tye/ 67 | 68 | # ASP.NET Scaffolding 69 | ScaffoldingReadMe.txt 70 | 71 | # StyleCop 72 | StyleCopReport.xml 73 | 74 | # Files built by Visual Studio 75 | *_i.c 76 | *_p.c 77 | *_h.h 78 | *.ilk 79 | *.meta 80 | *.obj 81 | *.iobj 82 | *.pch 83 | *.pdb 84 | *.ipdb 85 | *.pgc 86 | *.pgd 87 | *.rsp 88 | *.sbr 89 | *.tlb 90 | *.tli 91 | *.tlh 92 | *.tmp 93 | *.tmp_proj 94 | *_wpftmp.csproj 95 | *.log 96 | *.tlog 97 | *.vspscc 98 | *.vssscc 99 | .builds 100 | *.pidb 101 | *.svclog 102 | *.scc 103 | 104 | # Chutzpah Test files 105 | _Chutzpah* 106 | 107 | # Visual C++ cache files 108 | ipch/ 109 | *.aps 110 | *.ncb 111 | *.opendb 112 | *.opensdf 113 | *.sdf 114 | *.cachefile 115 | *.VC.db 116 | *.VC.VC.opendb 117 | 118 | # Visual Studio profiler 119 | *.psess 120 | *.vsp 121 | *.vspx 122 | *.sap 123 | 124 | # Visual Studio Trace Files 125 | *.e2e 126 | 127 | # TFS 2012 Local Workspace 128 | $tf/ 129 | 130 | # Guidance Automation Toolkit 131 | *.gpState 132 | 133 | # ReSharper is a .NET coding add-in 134 | _ReSharper*/ 135 | *.[Rr]e[Ss]harper 136 | *.DotSettings.user 137 | 138 | # TeamCity is a build add-in 139 | _TeamCity* 140 | 141 | # DotCover is a Code Coverage Tool 142 | *.dotCover 143 | 144 | # AxoCover is a Code Coverage Tool 145 | .axoCover/* 146 | !.axoCover/settings.json 147 | 148 | # Coverlet is a free, cross platform Code Coverage Tool 149 | coverage*.json 150 | coverage*.xml 151 | coverage*.info 152 | 153 | # Visual Studio code coverage results 154 | *.coverage 155 | *.coveragexml 156 | 157 | # NCrunch 158 | _NCrunch_* 159 | .*crunch*.local.xml 160 | nCrunchTemp_* 161 | 162 | # MightyMoose 163 | *.mm.* 164 | AutoTest.Net/ 165 | 166 | # Web workbench (sass) 167 | .sass-cache/ 168 | 169 | # Installshield output folder 170 | [Ee]xpress/ 171 | 172 | # DocProject is a documentation generator add-in 173 | DocProject/buildhelp/ 174 | DocProject/Help/*.HxT 175 | DocProject/Help/*.HxC 176 | DocProject/Help/*.hhc 177 | DocProject/Help/*.hhk 178 | DocProject/Help/*.hhp 179 | DocProject/Help/Html2 180 | DocProject/Help/html 181 | 182 | # Click-Once directory 183 | publish/ 184 | 185 | # Publish Web Output 186 | *.[Pp]ublish.xml 187 | *.azurePubxml 188 | # Note: Comment the next line if you want to checkin your web deploy settings, 189 | # but database connection strings (with potential passwords) will be unencrypted 190 | *.pubxml 191 | *.publishproj 192 | 193 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 194 | # checkin your Azure Web App publish settings, but sensitive information contained 195 | # in these scripts will be unencrypted 196 | PublishScripts/ 197 | 198 | # NuGet Packages 199 | *.nupkg 200 | # NuGet Symbol Packages 201 | *.snupkg 202 | # The packages folder can be ignored because of Package Restore 203 | **/[Pp]ackages/* 204 | # except build/, which is used as an MSBuild target. 205 | !**/[Pp]ackages/build/ 206 | # Uncomment if necessary however generally it will be regenerated when needed 207 | #!**/[Pp]ackages/repositories.config 208 | # NuGet v3's project.json files produces more ignorable files 209 | *.nuget.props 210 | *.nuget.targets 211 | 212 | # Microsoft Azure Build Output 213 | csx/ 214 | *.build.csdef 215 | 216 | # Microsoft Azure Emulator 217 | ecf/ 218 | rcf/ 219 | 220 | # Windows Store app package directories and files 221 | AppPackages/ 222 | BundleArtifacts/ 223 | Package.StoreAssociation.xml 224 | _pkginfo.txt 225 | *.appx 226 | *.appxbundle 227 | *.appxupload 228 | 229 | # Visual Studio cache files 230 | # files ending in .cache can be ignored 231 | *.[Cc]ache 232 | # but keep track of directories ending in .cache 233 | !?*.[Cc]ache/ 234 | 235 | # Others 236 | ClientBin/ 237 | ~$* 238 | *~ 239 | *.dbmdl 240 | *.dbproj.schemaview 241 | *.jfm 242 | *.pfx 243 | *.publishsettings 244 | orleans.codegen.cs 245 | 246 | # Including strong name files can present a security risk 247 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 248 | #*.snk 249 | 250 | # Since there are multiple workflows, uncomment next line to ignore bower_components 251 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 252 | #bower_components/ 253 | 254 | # RIA/Silverlight projects 255 | Generated_Code/ 256 | 257 | # Backup & report files from converting an old project file 258 | # to a newer Visual Studio version. Backup files are not needed, 259 | # because we have git ;-) 260 | _UpgradeReport_Files/ 261 | Backup*/ 262 | UpgradeLog*.XML 263 | UpgradeLog*.htm 264 | ServiceFabricBackup/ 265 | *.rptproj.bak 266 | 267 | # SQL Server files 268 | *.mdf 269 | *.ldf 270 | *.ndf 271 | 272 | # Business Intelligence projects 273 | *.rdl.data 274 | *.bim.layout 275 | *.bim_*.settings 276 | *.rptproj.rsuser 277 | *- [Bb]ackup.rdl 278 | *- [Bb]ackup ([0-9]).rdl 279 | *- [Bb]ackup ([0-9][0-9]).rdl 280 | 281 | # Microsoft Fakes 282 | FakesAssemblies/ 283 | 284 | # GhostDoc plugin setting file 285 | *.GhostDoc.xml 286 | 287 | # Node.js Tools for Visual Studio 288 | .ntvs_analysis.dat 289 | node_modules/ 290 | 291 | # Visual Studio 6 build log 292 | *.plg 293 | 294 | # Visual Studio 6 workspace options file 295 | *.opt 296 | 297 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 298 | *.vbw 299 | 300 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 301 | *.vbp 302 | 303 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 304 | *.dsw 305 | *.dsp 306 | 307 | # Visual Studio 6 technical files 308 | *.ncb 309 | *.aps 310 | 311 | # Visual Studio LightSwitch build output 312 | **/*.HTMLClient/GeneratedArtifacts 313 | **/*.DesktopClient/GeneratedArtifacts 314 | **/*.DesktopClient/ModelManifest.xml 315 | **/*.Server/GeneratedArtifacts 316 | **/*.Server/ModelManifest.xml 317 | _Pvt_Extensions 318 | 319 | # Paket dependency manager 320 | .paket/paket.exe 321 | paket-files/ 322 | 323 | # FAKE - F# Make 324 | .fake/ 325 | 326 | # CodeRush personal settings 327 | .cr/personal 328 | 329 | # Python Tools for Visual Studio (PTVS) 330 | __pycache__/ 331 | *.pyc 332 | 333 | # Cake - Uncomment if you are using it 334 | # tools/** 335 | # !tools/packages.config 336 | 337 | # Tabs Studio 338 | *.tss 339 | 340 | # Telerik's JustMock configuration file 341 | *.jmconfig 342 | 343 | # BizTalk build output 344 | *.btp.cs 345 | *.btm.cs 346 | *.odx.cs 347 | *.xsd.cs 348 | 349 | # OpenCover UI analysis results 350 | OpenCover/ 351 | 352 | # Azure Stream Analytics local run output 353 | ASALocalRun/ 354 | 355 | # MSBuild Binary and Structured Log 356 | *.binlog 357 | 358 | # NVidia Nsight GPU debugger configuration file 359 | *.nvuser 360 | 361 | # MFractors (Xamarin productivity tool) working folder 362 | .mfractor/ 363 | 364 | # Local History for Visual Studio 365 | .localhistory/ 366 | 367 | # Visual Studio History (VSHistory) files 368 | .vshistory/ 369 | 370 | # BeatPulse healthcheck temp database 371 | healthchecksdb 372 | 373 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 374 | MigrationBackup/ 375 | 376 | # Ionide (cross platform F# VS Code tools) working folder 377 | .ionide/ 378 | 379 | # Fody - auto-generated XML schema 380 | FodyWeavers.xsd 381 | 382 | # VS Code files for those working on multiple tools 383 | .vscode/* 384 | !.vscode/settings.json 385 | !.vscode/tasks.json 386 | !.vscode/launch.json 387 | !.vscode/extensions.json 388 | *.code-workspace 389 | 390 | # Local History for Visual Studio Code 391 | .history/ 392 | 393 | # Windows Installer files from build outputs 394 | *.cab 395 | *.msi 396 | *.msix 397 | *.msm 398 | *.msp 399 | 400 | # JetBrains Rider 401 | *.sln.iml 402 | 403 | ## 404 | ## Visual studio for Mac 405 | ## 406 | 407 | 408 | # globs 409 | Makefile.in 410 | *.userprefs 411 | *.usertasks 412 | config.make 413 | config.status 414 | aclocal.m4 415 | install-sh 416 | autom4te.cache/ 417 | *.tar.gz 418 | tarballs/ 419 | test-results/ 420 | 421 | # Mac bundle stuff 422 | *.dmg 423 | *.app 424 | 425 | # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 426 | # General 427 | .DS_Store 428 | .AppleDouble 429 | .LSOverride 430 | 431 | # Icon must end with two \r 432 | Icon 433 | 434 | 435 | # Thumbnails 436 | ._* 437 | 438 | # Files that might appear in the root of a volume 439 | .DocumentRevisions-V100 440 | .fseventsd 441 | .Spotlight-V100 442 | .TemporaryItems 443 | .Trashes 444 | .VolumeIcon.icns 445 | .com.apple.timemachine.donotpresent 446 | 447 | # Directories potentially created on remote AFP share 448 | .AppleDB 449 | .AppleDesktop 450 | Network Trash Folder 451 | Temporary Items 452 | .apdisk 453 | 454 | # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore 455 | # Windows thumbnail cache files 456 | Thumbs.db 457 | ehthumbs.db 458 | ehthumbs_vista.db 459 | 460 | # Dump file 461 | *.stackdump 462 | 463 | # Folder config file 464 | [Dd]esktop.ini 465 | 466 | # Recycle Bin used on file shares 467 | $RECYCLE.BIN/ 468 | 469 | # Windows Installer files 470 | *.cab 471 | *.msi 472 | *.msix 473 | *.msm 474 | *.msp 475 | 476 | # Windows shortcuts 477 | *.lnk 478 | 479 | .idea 480 | 481 | Resource.designer.cs 482 | nupkgs/ -------------------------------------------------------------------------------- /src/Fabulous.Tests/APISketchTests/TestUI.Widgets.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous.Tests.APISketchTests 2 | 3 | module TestUI_Widgets = 4 | 5 | open System 6 | open System.Runtime.CompilerServices 7 | open Fabulous 8 | open Fabulous.StackAllocatedCollections 9 | open Fabulous.StackAllocatedCollections.StackList 10 | open Platform 11 | open TestUI_Attributes 12 | open TestUI_ViewNode 13 | open TestUI_Component 14 | 15 | 16 | //----WidgetsBuilderCE--- 17 | 18 | 19 | 20 | 21 | //-------Widgets 22 | 23 | module Widgets = 24 | let register<'T when 'T :> TestViewElement and 'T: (new: unit -> 'T)> () = 25 | let key = WidgetDefinitionStore.getNextKey() 26 | 27 | let definition = 28 | { Key = key 29 | Name = typeof<'T>.Name 30 | TargetType = typeof<'T> 31 | CreateView = 32 | fun (widget, envContext, treeContext, parentNode) -> 33 | // let name = typeof<'T>.Name 34 | // printfn $"Creating view for {name}" 35 | 36 | let view = new 'T() 37 | let weakReference = WeakReference(view) 38 | 39 | let parentNode = 40 | match parentNode with 41 | | ValueNone -> None 42 | | ValueSome parent -> Some parent 43 | 44 | let viewNode = new ViewNode(parentNode, envContext, treeContext, weakReference) 45 | 46 | view.PropertyBag.Add(ViewNode.ViewNodeProperty, viewNode) 47 | 48 | let oldWidget: Widget voption = ValueNone 49 | 50 | Reconciler.update treeContext.CanReuseView oldWidget widget viewNode 51 | struct (viewNode :> IViewNode, box view) 52 | AttachView = fun (_widget, _envContext, _treeContext, _parentNode, _view) -> failwith "not implemented" } 53 | 54 | WidgetDefinitionStore.set key definition 55 | key 56 | 57 | 58 | 59 | 60 | 61 | //-----MARKERS----------- 62 | type IMarker = interface end 63 | 64 | type TextMarker = 65 | inherit IMarker 66 | 67 | type TestLabelMarker = 68 | inherit TextMarker 69 | 70 | type TestButtonMarker = 71 | inherit TextMarker 72 | 73 | type TestStackMarker = 74 | inherit IMarker 75 | 76 | type TestNumericBagMarker = 77 | inherit IMarker 78 | 79 | //---------------- 80 | 81 | /// ------------ Extensions 82 | [] 83 | type WidgetExtensions() = 84 | [] 85 | static member inline automationId<'msg, 'marker when 'msg: equality and 'marker :> IMarker>(this: WidgetBuilder<'msg, 'marker>, value: string) = 86 | this.AddScalar(Attributes.Automation.AutomationId.WithValue(value)) 87 | 88 | [] 89 | static member inline automationId<'msg, 'marker, 'itemMarker when 'msg: equality and 'marker :> IMarker> 90 | (this: CollectionBuilder<'msg, 'marker, 'itemMarker>, value: string) 91 | = 92 | this.AddScalar(Attributes.Automation.AutomationId.WithValue(value)) 93 | 94 | [] 95 | static member inline textColor<'msg, 'marker when 'msg: equality and 'marker :> TextMarker>(this: WidgetBuilder<'msg, 'marker>, value: string) = 96 | this.AddScalar(Attributes.TextStyle.TextColor.WithValue(value)) 97 | 98 | 99 | [] 100 | static member inline record<'msg, 'marker when 'msg: equality and 'marker :> TestLabelMarker>(this: WidgetBuilder<'msg, 'marker>, value: bool) = 101 | this.AddScalar(Attributes.Text.Record.WithValue(value)) 102 | 103 | 104 | [] 105 | static member inline tap<'msg, 'marker when 'msg: equality and 'marker :> TestButtonMarker>(this: WidgetBuilder<'msg, 'marker>, value: 'msg) = 106 | this.AddScalar(Attributes.Button.Tap.WithValue(value)) 107 | 108 | 109 | [] 110 | static member inline tap2<'msg, 'marker when 'msg: equality and 'marker :> TestButtonMarker>(this: WidgetBuilder<'msg, 'marker>, value: 'msg) = 111 | this.AddScalar(Attributes.Button.Tap2.WithValue(value)) 112 | 113 | 114 | [] 115 | static member inline tapContainer<'msg, 'marker when 'msg: equality and 'marker :> TestStackMarker>(this: WidgetBuilder<'msg, 'marker>, value: 'msg) = 116 | this.AddScalar(Attributes.Container.Tap.WithValue(value)) 117 | 118 | ///---------------- 119 | ///---------------- 120 | 121 | [] 122 | type View private () = 123 | static let TestLabelKey = Widgets.register() 124 | static let TestButtonKey = Widgets.register() 125 | static let TestStackKey = Widgets.register() 126 | static let TestNumericBagKey = Widgets.register() 127 | 128 | static member Label(text: string) = 129 | WidgetBuilder<'msg, TestLabelMarker>(TestLabelKey, Attributes.Text.Text.WithValue(text)) 130 | 131 | 132 | static member Button(text: string, onClicked: 'msg) = 133 | WidgetBuilder<'msg, TestButtonMarker>(TestButtonKey, Attributes.Text.Text.WithValue(text), Attributes.Button.Pressed.WithValue(onClicked)) 134 | 135 | static member BoxedNumericBag(one, two, three) = 136 | WidgetBuilder<'msg, TestNumericBagMarker>( 137 | TestNumericBagKey, 138 | Attributes.NumericBag.BoxedValueOne.WithValue(one), 139 | Attributes.NumericBag.BoxedValueTwo.WithValue(two), 140 | Attributes.NumericBag.BoxedValueThree.WithValue(three) 141 | ) 142 | 143 | static member InlineNumericBag(one, two, three) = 144 | WidgetBuilder<'msg, TestNumericBagMarker>( 145 | TestNumericBagKey, 146 | Attributes.NumericBag.InlineValueOne.WithValue(one, (fun x -> x)), 147 | Attributes.NumericBag.InlineValueTwo.WithValue(two, (fun x -> x)), 148 | // Attributes.NumericBag.InlineValueOne.WithValue(one, Attributes.func), 149 | // Attributes.NumericBag.InlineValueTwo.WithValue(two, Attributes.func), 150 | Attributes.NumericBag.InlineValueThree.WithValue(three, BitConverter.DoubleToUInt64Bits) 151 | ) 152 | 153 | static member Stack<'msg, 'marker when 'msg: equality and 'marker :> IMarker>() = 154 | CollectionBuilder<'msg, TestStackMarker, 'marker>(TestStackKey, StackList.empty(), Attributes.Container.Children) 155 | 156 | [] 157 | type CollectionBuilderExtensions = 158 | [] 159 | static member inline Yield<'msg, 'marker, 'itemMarker when 'msg: equality and 'itemMarker :> IMarker> 160 | (_: CollectionBuilder<'msg, 'marker, IMarker>, x: WidgetBuilder<'msg, 'itemMarker>) 161 | : Content<'msg> = 162 | CollectionBuilder.yieldImpl x 163 | 164 | [] 165 | static member inline Yield<'msg, 'marker, 'itemMarker when 'msg: equality and 'itemMarker :> IMarker> 166 | (_: CollectionBuilder<'msg, 'marker, IMarker>, x: WidgetBuilder<'msg, Memo.Memoized<'itemMarker>>) 167 | : Content<'msg> = 168 | CollectionBuilder.yieldImpl x 169 | 170 | ///------------------ 171 | type StatefulView<'arg, 'model, 'msg, 'marker when 'msg: equality> = 172 | { Init: 'arg -> 'model 173 | Update: 'msg -> 'model -> 'model 174 | View: 'model -> WidgetBuilder<'msg, 'marker> } 175 | 176 | module StatefulWidget = 177 | let mkSimpleView init update view : StatefulView<_, _, _, _> = 178 | { Init = init 179 | Update = update 180 | View = view } 181 | 182 | 183 | module Run = 184 | type Instance<'arg, 'model, 'msg, 'marker when 'msg: equality>(program: StatefulView<'arg, 'model, 'msg, 'marker>) = 185 | let mutable state: ('model * obj * Widget) option = None 186 | 187 | let logger = 188 | { Log = fun _ -> () 189 | MinLogLevel = LogLevel.Fatal } 190 | 191 | member private x.envContext = new EnvironmentContext(logger) 192 | 193 | member private x.treeContext: ViewTreeContext = 194 | { CanReuseView = ViewHelpers.canReuseView 195 | GetViewNode = ViewNode.getViewNode 196 | Logger = logger 197 | Dispatch = fun msg -> unbox<'msg> msg |> x.ProcessMessage 198 | GetComponent = Component.getComponent 199 | SetComponent = Component.setComponent 200 | SyncAction = fun fn -> fn() } 201 | 202 | member x.ProcessMessage(msg: 'msg) = 203 | match state with 204 | | None -> () 205 | | Some(m, target, oldWidget) -> 206 | let newModel = program.Update msg m 207 | let newWidget = program.View(newModel).Compile() 208 | 209 | // is it better to have a Kind prop instead 210 | // basically we care if it is exactly the same widget type as it was before 211 | // TODO support mount + unmount 212 | // TODO possibly introduce some notion of a platform/runtime context 213 | // that can mount and unmount nodes 214 | 215 | let viewNode = ViewNode.getViewNode target 216 | 217 | if newWidget.Key <> oldWidget.Key then 218 | failwith "type mismatch!" 219 | 220 | state <- Some(newModel, target, newWidget) 221 | 222 | Reconciler.update x.treeContext.CanReuseView (ValueSome oldWidget) newWidget viewNode 223 | () 224 | 225 | member x.Start(arg: 'arg) = 226 | let model = program.Init(arg) 227 | let widget = program.View(model).Compile() 228 | let widgetDef = WidgetDefinitionStore.get widget.Key 229 | 230 | let struct (_node, view) = 231 | widgetDef.CreateView(widget, x.envContext, x.treeContext, ValueNone) 232 | 233 | state <- Some(model, view, widget) 234 | 235 | view :?> TestViewElement 236 | 237 | //module View = 238 | // let inline map (fn: 'oldMsg -> 'newMsg) (this: WidgetBuilder<'oldMsg, 'marker>) : WidgetBuilder<'newMsg, 'marker> = 239 | // this.MapMsg fn 240 | -------------------------------------------------------------------------------- /src/Fabulous/Cmd.fs: -------------------------------------------------------------------------------- 1 | namespace Fabulous 2 | 3 | open System.Threading 4 | open System.Threading.Tasks 5 | 6 | /// Dispatch - feed new message into the processing loop 7 | type Dispatch<'msg> = 'msg -> unit 8 | 9 | /// Subscription - return immediately, but may schedule dispatch of a message at any time 10 | type Effect<'msg> = Dispatch<'msg> -> unit 11 | 12 | /// Cmd - container for effects that may produce messages 13 | type Cmd<'msg> = Effect<'msg> list 14 | 15 | /// Cmd module for creating and manipulating commands 16 | [] 17 | module Cmd = 18 | /// Execute the commands using the supplied dispatcher 19 | let internal exec onError (dispatch: Dispatch<'msg>) (cmd: Cmd<'msg>) = 20 | cmd 21 | |> List.iter(fun call -> 22 | try 23 | call dispatch 24 | with ex -> 25 | onError ex) 26 | 27 | /// None - no commands, also known as `[]` 28 | let none: Cmd<'msg> = [] 29 | 30 | /// When emitting the message, map to another type 31 | let map (f: 'a -> 'msg) (cmd: Cmd<'a>) : Cmd<'msg> = 32 | cmd |> List.map(fun g -> (fun dispatch -> f >> dispatch) >> g) 33 | 34 | /// Aggregate multiple commands 35 | let batch (cmds: Cmd<'msg> list) : Cmd<'msg> = List.concat cmds 36 | 37 | /// Command to call the effect 38 | let ofEffect (effect: Effect<'msg>) : Cmd<'msg> = [ effect ] 39 | 40 | /// Command to issue a specific message 41 | let ofMsg (msg: 'msg) : Cmd<'msg> = [ fun dispatch -> dispatch msg ] 42 | 43 | /// Command to issue a specific message, only when Option.IsSome = true 44 | let ofMsgOption (msg: 'msg option) : Cmd<'msg> = 45 | [ fun dispatch -> 46 | match msg with 47 | | None -> () 48 | | Some msg -> dispatch msg ] 49 | 50 | module OfFunc = 51 | /// Command to evaluate a simple function and map the result 52 | /// into success or error (of exception) 53 | let either (task: 'a -> _) (arg: 'a) (ofSuccess: _ -> 'msg) (ofError: _ -> 'msg) : Cmd<'msg> = 54 | let bind dispatch = 55 | try 56 | task arg |> (ofSuccess >> dispatch) 57 | with x -> 58 | x |> (ofError >> dispatch) 59 | 60 | [ bind ] 61 | 62 | /// Command to evaluate a simple function and map the success to a message 63 | /// discarding any possible error 64 | let perform (task: 'a -> _) (arg: 'a) (ofSuccess: _ -> 'msg) : Cmd<'msg> = 65 | let bind dispatch = 66 | try 67 | task arg |> (ofSuccess >> dispatch) 68 | with x -> 69 | () 70 | 71 | [ bind ] 72 | 73 | /// Command to evaluate a simple function and map the error (in case of exception) 74 | let attempt (task: 'a -> unit) (arg: 'a) (ofError: _ -> 'msg) : Cmd<'msg> = 75 | let bind dispatch = 76 | try 77 | task arg 78 | with x -> 79 | x |> (ofError >> dispatch) 80 | 81 | [ bind ] 82 | 83 | module OfAsyncWith = 84 | /// Command that will evaluate an async block and map the result 85 | /// into success or error (of exception) 86 | let either (start: Async -> unit) (task: 'a -> Async<_>) (arg: 'a) (ofSuccess: _ -> 'msg) (ofError: _ -> 'msg) : Cmd<'msg> = 87 | let bind dispatch = 88 | async { 89 | let! r = task arg |> Async.Catch 90 | 91 | dispatch( 92 | match r with 93 | | Choice1Of2 x -> ofSuccess x 94 | | Choice2Of2 x -> ofError x 95 | ) 96 | } 97 | 98 | [ bind >> start ] 99 | 100 | /// Command that will evaluate an async block and map the success 101 | let perform (start: Async -> unit) (task: 'a -> Async<_>) (arg: 'a) (ofSuccess: _ -> 'msg) : Cmd<'msg> = 102 | let bind dispatch = 103 | async { 104 | let! r = task arg |> Async.Catch 105 | 106 | match r with 107 | | Choice1Of2 x -> dispatch(ofSuccess x) 108 | | _ -> () 109 | } 110 | 111 | [ bind >> start ] 112 | 113 | /// Command that will evaluate an async block and map the error (of exception) 114 | let attempt (start: Async -> unit) (task: 'a -> Async<_>) (arg: 'a) (ofError: _ -> 'msg) : Cmd<'msg> = 115 | let bind dispatch = 116 | async { 117 | let! r = task arg |> Async.Catch 118 | 119 | match r with 120 | | Choice2Of2 x -> dispatch(ofError x) 121 | | _ -> () 122 | } 123 | 124 | [ bind >> start ] 125 | 126 | /// Command that will evaluate an async block and map the success 127 | let performOption (start: Async -> unit) (task: 'a -> Async<_ option>) (arg: 'a) (ofSuccess: _ -> 'msg) : Cmd<'msg> = 128 | let bind dispatch = 129 | async { 130 | let! r = task arg 131 | 132 | match r with 133 | | Some x -> dispatch(ofSuccess x) 134 | | None -> () 135 | } 136 | 137 | [ bind >> start ] 138 | 139 | module OfAsync = 140 | /// Command that will evaluate an async block and map the result 141 | /// into success or error (of exception) 142 | let inline either (task: 'a -> Async<_>) (arg: 'a) (ofSuccess: _ -> 'msg) (ofError: _ -> 'msg) : Cmd<'msg> = 143 | OfAsyncWith.either Async.Start task arg ofSuccess ofError 144 | 145 | /// Command that will evaluate an async block and map the success 146 | let inline perform (task: 'a -> Async<_>) (arg: 'a) (ofSuccess: _ -> 'msg) : Cmd<'msg> = 147 | OfAsyncWith.perform Async.Start task arg ofSuccess 148 | 149 | /// Command that will evaluate an async block and map the error (of exception) 150 | let inline attempt (task: 'a -> Async<_>) (arg: 'a) (ofError: _ -> 'msg) : Cmd<'msg> = 151 | OfAsyncWith.attempt Async.Start task arg ofError 152 | 153 | let inline msg (task: Async<'msg>) = 154 | OfAsyncWith.perform Async.Start (fun () -> task) () id 155 | 156 | let inline msgOption (task: Async<'msg option>) = 157 | OfAsyncWith.performOption Async.Start (fun () -> task) () id 158 | 159 | module OfAsyncImmediate = 160 | /// Command that will evaluate an async block and map the result 161 | /// into success or error (of exception) 162 | let inline either (task: 'a -> Async<_>) (arg: 'a) (ofSuccess: _ -> 'msg) (ofError: _ -> 'msg) : Cmd<'msg> = 163 | OfAsyncWith.either Async.StartImmediate task arg ofSuccess ofError 164 | 165 | /// Command that will evaluate an async block and map the success 166 | let inline perform (task: 'a -> Async<_>) (arg: 'a) (ofSuccess: _ -> 'msg) : Cmd<'msg> = 167 | OfAsyncWith.perform Async.StartImmediate task arg ofSuccess 168 | 169 | /// Command that will evaluate an async block and map the error (of exception) 170 | let inline attempt (task: 'a -> Async<_>) (arg: 'a) (ofError: _ -> 'msg) : Cmd<'msg> = 171 | OfAsyncWith.attempt Async.StartImmediate task arg ofError 172 | 173 | module OfTask = 174 | /// Command to call a task and map the results 175 | let inline either 176 | ([] task: 'a -> Task<_>) 177 | (arg: 'a) 178 | ([] ofSuccess: _ -> 'msg) 179 | ([] ofError: _ -> 'msg) 180 | : Cmd<'msg> = 181 | [ fun dispatch -> 182 | backgroundTask { 183 | try 184 | let! r = task arg 185 | ofSuccess r |> dispatch 186 | with e -> 187 | ofError e |> dispatch 188 | } 189 | |> ignore> ] 190 | 191 | /// Command to call a task and map the success 192 | let inline perform ([] task: 'a -> Task<_>) (arg: 'a) ([] ofSuccess: _ -> 'msg) : Cmd<'msg> = 193 | [ fun dispatch -> 194 | backgroundTask { 195 | try 196 | let! r = task arg 197 | ofSuccess r |> dispatch 198 | with _ -> 199 | () 200 | } 201 | |> ignore> ] 202 | 203 | /// Command to call a task and map the error 204 | let inline attempt ([] task: 'a -> #Task) (arg: 'a) ([] ofError: _ -> 'msg) : Cmd<'msg> = 205 | [ fun dispatch -> 206 | backgroundTask { 207 | try 208 | do! task arg 209 | with e -> 210 | ofError e |> dispatch 211 | } 212 | |> ignore> ] 213 | 214 | let inline msg (task: Task<'msg>) = perform (fun () -> task) () id 215 | 216 | let inline msgOption (task: Task<'msg option>) = 217 | [ fun dispatch -> 218 | backgroundTask { 219 | let! r = task 220 | 221 | match r with 222 | | Some x -> dispatch x 223 | | None -> () 224 | } 225 | |> ignore> ] 226 | 227 | /// Command to issue a message if no other message has been issued within the specified timeout 228 | let debounce (timeout: int) (fn: 'value -> 'msg) : 'value -> Cmd<'msg> = 229 | let funLock = obj() 230 | let mutable cts: CancellationTokenSource = null 231 | 232 | fun (value: 'value) -> 233 | [ fun dispatch -> 234 | lock funLock (fun () -> 235 | if not(isNull cts) then 236 | cts.Cancel() 237 | cts.Dispose() 238 | 239 | cts <- new CancellationTokenSource() 240 | 241 | Async.Start( 242 | async { 243 | do! Async.Sleep(timeout) 244 | 245 | lock funLock (fun () -> 246 | dispatch(fn value) 247 | 248 | if not(isNull cts) then 249 | cts.Dispose() 250 | cts <- null) 251 | }, 252 | cts.Token 253 | )) ] 254 | --------------------------------------------------------------------------------