From 20832df4c7e5ea3ea9b3ee3c37e5ec66a234b669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Larivi=C3=A8re?= Date: Mon, 20 Nov 2023 19:32:19 +0100 Subject: [PATCH 1/7] First draft of component support --- src/Fabulous/Component.fs | 344 +++++++++++++++++++++++++++++++++++ src/Fabulous/Fabulous.fsproj | 1 + 2 files changed, 345 insertions(+) create mode 100644 src/Fabulous/Component.fs diff --git a/src/Fabulous/Component.fs b/src/Fabulous/Component.fs new file mode 100644 index 000000000..3b5495bb5 --- /dev/null +++ b/src/Fabulous/Component.fs @@ -0,0 +1,344 @@ +namespace Fabulous + +open System +open System.ComponentModel +open System.Runtime.CompilerServices +open System.Collections.Generic +open Fabulous.ScalarAttributeDefinitions + +////////////// CONTEXT ////////////// +type ComponentContext() = + let values = Dictionary() + let mutable _current = -1 + + let renderNeeded = Event() + member this.RenderNeeded = renderNeeded.Publish + member this.NeedsRender() = renderNeeded.Trigger() + + member this.MoveNext() = + _current <- _current + 1 + _current + + member this.Current + with get () = + match values.TryGetValue(_current) with + | false, _ -> ValueNone + | true, value -> ValueSome value + + member this.SetCurrentValue(value: 'T) = + values[_current] <- value + + member this.SetValue(key: int, value: 'T) = + values[key] <- value + this.NeedsRender() + + member this.AfterRender() = + _current <- -1 + + + +////////////// ViewBuilder ////////////// +type ComponentBody = delegate of ComponentContext -> Widget +type ComponentBodyBuilder<'msg, 'marker> = delegate of ComponentContext -> WidgetBuilder<'msg, 'marker> + +type ViewBuilder() = + member inline this.Yield(widget: WidgetBuilder<'msg, 'marker>) = + ComponentBodyBuilder(fun ctx -> widget) + + member inline this.Combine([] a: ComponentBodyBuilder<'msg, 'marker>, [] b: ComponentBodyBuilder<'msg, 'marker>) = + ComponentBodyBuilder(fun ctx -> + let _ = a.Invoke(ctx) // discard the previous widget in the chain + let result = b.Invoke(ctx) + result + ) + + member inline this.Delay([] fn: unit -> ComponentBodyBuilder<'msg, 'marker>) = + ComponentBodyBuilder(fun ctx -> + let sub = fn() + sub.Invoke(ctx) + ) + + member inline this.Run([] result: ComponentBodyBuilder<'msg, 'marker>) = + result + +[] +module ViewBuilder = + let view = ViewBuilder() + + + + + +////////////// Component holder ////////////// + +type Component(treeContext: ViewTreeContext, body: ComponentBody, context: ComponentContext) = + let mutable _body = body + let mutable _context = context + let mutable _widget = Unchecked.defaultof<_> + let mutable _view = null + let mutable _contextSubscription: IDisposable = null + + // TODO: This is a big code smell. We should not do this but I can't think of a better way to do it right now. + // The implementation of this method is set by the consuming project: Fabulous.XamarinForms, Fabulous.Maui, Fabulous.Avalonia + static let mutable _setAttachedComponent: obj -> Component -> unit = fun _ _ -> failwith "Please call Component.SetComponentFunctions() before using Component" + static let mutable _getAttachedComponent: obj -> Component = fun _ -> failwith "Please call Component.SetComponentFunctions() before using Component" + static member SetComponentFunctions(get: obj -> Component, set: obj -> Component -> unit) = + _getAttachedComponent <- get + _setAttachedComponent <- set + + static member GetAttachedComponent(view: obj) = _getAttachedComponent view + static member SetAttachedComponent(view: obj, comp: Component) = _setAttachedComponent view comp + + member this.Body + with get () = _body + and set value = + _body <- value + this.Render() + + member this.Context + with get () = _context + and set value = + if _contextSubscription <> null then + _contextSubscription.Dispose() + + _context <- value + + _contextSubscription <- this.Context.RenderNeeded.Subscribe(this.Render) + + this.Render() + + member this.CreateView() = + _contextSubscription <- this.Context.RenderNeeded.Subscribe(this.Render) + + let rootWidget = this.Body.Invoke(context) + _widget <- rootWidget + + this.Context.AfterRender() + + let widgetDef = WidgetDefinitionStore.get rootWidget.Key + let struct (node, view) = widgetDef.CreateView(rootWidget, treeContext, ValueNone) + _view <- view + + Component.SetAttachedComponent(view, this) + + struct (node, view) + + member this.Render() = + let prevRootWidget = _widget + let currRootWidget = this.Body.Invoke(context) + _widget <- currRootWidget + + this.Context.AfterRender() + + let viewNode = treeContext.GetViewNode _view + + Reconciler.update treeContext.CanReuseView (ValueSome prevRootWidget) currRootWidget viewNode + + interface IDisposable with + member this.Dispose() = + if _contextSubscription <> null then + _contextSubscription.Dispose() + _contextSubscription <- null + + + + +////////////// Component widget ////////////// +module Component = + /// TODO: This is actually broken. On every call of the parent, the body will be reassigned to the Component triggering a re-render because of the noCompare. + /// This is not what was expected. The body should actually be invalidated based on its context. + let Body: SimpleScalarAttributeDefinition = Attributes.defineSimpleScalar "Component_Body" ScalarAttributeComparers.noCompare (fun _ currOpt node -> + let target = Component.GetAttachedComponent(node.Target) + match currOpt with + | ValueNone -> failwith "Component widget must have a body" + | ValueSome curr -> target.Body <- curr + ) + let Context: SimpleScalarAttributeDefinition = Attributes.defineSimpleScalar "Component_Context" ScalarAttributeComparers.equalityCompare (fun _ currOpt node -> + let target = Component.GetAttachedComponent(node.Target) + match currOpt with + | ValueNone -> target.Context <- ComponentContext() + | ValueSome curr -> target.Context <- curr + ) + + let WidgetKey = + let key = WidgetDefinitionStore.getNextKey() + + let definition = + { Key = key + Name = "Component" + TargetType = typeof + AttachView = fun _ -> failwith "Component widget cannot be attached" + CreateView = + fun (widget, treeContext, _) -> + match widget.ScalarAttributes with + | ValueNone -> failwith "Component widget must have a body" + | ValueSome attrs -> + let body = + match Array.tryFind (fun (attr: ScalarAttribute) -> attr.Key = Body.Key) attrs with + | Some attr -> attr.Value :?> ComponentBody + | None -> failwith "Component widget must have a body" + + let context = + match Array.tryFind (fun (attr: ScalarAttribute) -> attr.Key = Context.Key) attrs with + | Some attr -> attr.Value :?> ComponentContext + | None -> ComponentContext() + + let comp = new Component(treeContext, body, context) + let struct(node, view) = comp.CreateView() + + struct (node, view) } + + WidgetDefinitionStore.set key definition + + key + +[] +type ComponentExtensions = + [] + static member inline WithValue(this: SimpleScalarAttributeDefinition, [] value: ComponentBodyBuilder<'msg, 'marker>) = + let compiledBody = + ComponentBody(fun ctx -> + let widgetBuilder = value.Invoke(ctx) + widgetBuilder.Compile() + ) + + this.WithValue(compiledBody) + + +////////////// State ////////////// + +type StateRequest<'T> = delegate of unit -> 'T + +/// DESIGN: State<'T> is meant to be very short lived. +/// It is created on Bind (let!) and destroyed at the end of a single ViewBuilder CE execution. +/// 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. +/// +/// e.g. +/// +/// Button("Increment", fun () -> state.Set(state.Current + 1)) +/// +/// will become +/// +/// class Closure { +/// public State state; // Storing a struct on a class will allocate it on the heap +/// +/// public void Invoke() { +/// state.Set(state.Current + 1); +/// } +/// } +/// +/// class Program { +/// public void View() +/// { +/// var state = new State(...); +/// +/// // This will allocate both the closure and the state on the heap +/// // which the GC will have to clean up later +/// var closure = new Closure(state = state); +/// +/// return Button("Increment", closure); +/// } +/// } +/// +/// +/// The Set method is therefore marked inlinable to avoid creating a closure capturing State<'T> +/// Instead the closure will only capture Context (already a reference type), Key (int) and Current (can be consider to be obj). +/// The compiler will rewrite the lambda as follow: +/// Button("Increment", fun () -> ctx.SetValue(key, current + 1)) +/// +/// State<'T> is no longer involved in the closure and will be kept on the stack. +/// +/// One constraint of inlining is to have all used fields public: Context, Key, Current +/// But we don't wish to expose the Context and Key fields to the user, so we mark them as EditorBrowsable.Never +type [] State<'T>= + [] + val public Context: ComponentContext + + [] + val public Key: int + + val public Current: 'T + + new (ctx, key, value) = { Context = ctx; Key = key; Current = value } + + member inline this.Set(value: 'T) = + this.Context.SetValue(this.Key, value) + +[] +type StateExtensions = + [] + static member inline Bind(_: ViewBuilder, [] fn: StateRequest<'T>, [] continuation: State<'T> -> ComponentBodyBuilder<'msg, 'marker>) = + ComponentBodyBuilder<'msg, 'marker>(fun ctx -> + let key = ctx.MoveNext() + + let value = + match ctx.Current with + | ValueSome value -> unbox<'T> value + | ValueNone -> + let value = fn.Invoke() + ctx.SetCurrentValue(value) + value + + let state = State(ctx, key, value) + (continuation state).Invoke(ctx) + ) + +[] +module StateHelpers = + let inline state value = StateRequest(fun () -> value) + + +////////////// Binding ////////////// + +(* + +The idea of Binding is to listen to a State<'T> that is managed by another Context and be able to update it +while notifying the two Contexts involved (source and target) + +let child (count: BindingRequest) = + view { + let! boundCount = bind count + + Button($"Count is {boundCount.Value}", fun () -> boundCount.Set(boundCount.Value + 1)) + } + +let parent = + view { + let! count = state 0 + + VStack() { + Text($"Count is {count.Value}") + child (Binding.ofState count) + } + } + +*) + +type BindingRequest<'T> = delegate of unit -> State<'T> + +type [] Binding<'T> = + val public Context: ComponentContext + val public Source: State<'T> + + new (ctx, source) = { Context = ctx; Source = source } + + member inline this.Current = this.Source.Current + + member inline this.Set(value: 'T) = + this.Source.Set(value) + this.Context.NeedsRender() + +[] +type BindingExtensions = + [] + static member inline Bind(_: ViewBuilder, [] request: BindingRequest<'T>, [] continuation: Binding<'T> -> ComponentBodyBuilder<'msg, 'marker>) = + ComponentBodyBuilder(fun ctx -> + let source = request.Invoke() + let state = Binding<'T>(ctx, source) + (continuation state).Invoke(ctx) + ) + +[] +module BindingHelpers = + let inline ofState (source: State<'T>) = BindingRequest(fun () -> source) + let inline bind (binding: Binding<'T>) = binding \ No newline at end of file diff --git a/src/Fabulous/Fabulous.fsproj b/src/Fabulous/Fabulous.fsproj index 9ad49e76f..7b13b1479 100644 --- a/src/Fabulous/Fabulous.fsproj +++ b/src/Fabulous/Fabulous.fsproj @@ -40,6 +40,7 @@ + From 2213c8fbc691616b1f4439bfddb0795c431910d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Larivi=C3=A8re?= Date: Mon, 20 Nov 2023 22:27:26 +0100 Subject: [PATCH 2/7] Change component API ergonomics --- src/Fabulous/Component.fs | 56 ++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/src/Fabulous/Component.fs b/src/Fabulous/Component.fs index 3b5495bb5..156793150 100644 --- a/src/Fabulous/Component.fs +++ b/src/Fabulous/Component.fs @@ -41,34 +41,7 @@ type ComponentContext() = type ComponentBody = delegate of ComponentContext -> Widget type ComponentBodyBuilder<'msg, 'marker> = delegate of ComponentContext -> WidgetBuilder<'msg, 'marker> -type ViewBuilder() = - member inline this.Yield(widget: WidgetBuilder<'msg, 'marker>) = - ComponentBodyBuilder(fun ctx -> widget) - - member inline this.Combine([] a: ComponentBodyBuilder<'msg, 'marker>, [] b: ComponentBodyBuilder<'msg, 'marker>) = - ComponentBodyBuilder(fun ctx -> - let _ = a.Invoke(ctx) // discard the previous widget in the chain - let result = b.Invoke(ctx) - result - ) - - member inline this.Delay([] fn: unit -> ComponentBodyBuilder<'msg, 'marker>) = - ComponentBodyBuilder(fun ctx -> - let sub = fn() - sub.Invoke(ctx) - ) - - member inline this.Run([] result: ComponentBodyBuilder<'msg, 'marker>) = - result -[] -module ViewBuilder = - let view = ViewBuilder() - - - - - ////////////// Component holder ////////////// type Component(treeContext: ViewTreeContext, body: ComponentBody, context: ComponentContext) = @@ -192,6 +165,12 @@ module Component = key +[] +type ComponentModifiers = + [] + static member inline withContext(this: WidgetBuilder<'msg, 'marker>, context: ComponentContext) = + this.AddScalar(Component.Context.WithValue(context)) + [] type ComponentExtensions = [] @@ -203,6 +182,29 @@ type ComponentExtensions = ) this.WithValue(compiledBody) + +type ViewBuilder() = + member inline this.Yield(widget: WidgetBuilder<'msg, 'marker>) = + ComponentBodyBuilder(fun ctx -> widget) + + member inline this.Combine([] a: ComponentBodyBuilder<'msg, 'marker>, [] b: ComponentBodyBuilder<'msg, 'marker>) = + ComponentBodyBuilder(fun ctx -> + let _ = a.Invoke(ctx) // discard the previous widget in the chain + let result = b.Invoke(ctx) + result + ) + + member inline this.Delay([] fn: unit -> ComponentBodyBuilder<'msg, 'marker>) = + ComponentBodyBuilder(fun ctx -> + let sub = fn() + sub.Invoke(ctx) + ) + + member inline this.Run([] body: ComponentBodyBuilder<'msg, 'marker>) = + WidgetBuilder<'msg, 'marker>( + Component.WidgetKey, + Component.Body.WithValue(body) + ) ////////////// State ////////////// From 78f799889498168a3efd50f5892d29ce1b611aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Larivi=C3=A8re?= Date: Mon, 20 Nov 2023 23:21:52 +0100 Subject: [PATCH 3/7] Allow using modifiers on component directly --- src/Fabulous/Component.fs | 55 +++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/src/Fabulous/Component.fs b/src/Fabulous/Component.fs index 156793150..6803cac68 100644 --- a/src/Fabulous/Component.fs +++ b/src/Fabulous/Component.fs @@ -80,7 +80,7 @@ type Component(treeContext: ViewTreeContext, body: ComponentBody, context: Compo this.Render() - member this.CreateView() = + member this.CreateView(scalars: ScalarAttribute array voption, widgets: WidgetAttribute array voption, widgetColls: WidgetCollectionAttribute array voption) = _contextSubscription <- this.Context.RenderNeeded.Subscribe(this.Render) let rootWidget = this.Body.Invoke(context) @@ -89,6 +89,35 @@ type Component(treeContext: ViewTreeContext, body: ComponentBody, context: Compo this.Context.AfterRender() let widgetDef = WidgetDefinitionStore.get rootWidget.Key + + // Inject the attributes added to the component directly into the root widget + let scalars = + match scalars with + | ValueNone -> ValueNone + | ValueSome attrs -> ValueSome (Array.filter (fun (attr: ScalarAttribute) -> attr.DebugName <> "Component_Body" && attr.DebugName <> "Component_Context") attrs) + + let rootWidget: Widget = + { Key = rootWidget.Key + DebugName = rootWidget.DebugName + ScalarAttributes = + match struct (rootWidget.ScalarAttributes, scalars) with + | ValueNone, ValueNone -> ValueNone + | ValueSome attrs, ValueNone + | ValueNone, ValueSome attrs -> ValueSome attrs + | ValueSome widgetAttrs, ValueSome componentAttrs -> ValueSome (Array.append widgetAttrs componentAttrs) + WidgetAttributes = + match struct (rootWidget.WidgetAttributes, widgets) with + | ValueNone, ValueNone -> ValueNone + | ValueSome attrs, ValueNone + | ValueNone, ValueSome attrs -> ValueSome attrs + | ValueSome widgetAttrs, ValueSome componentAttrs -> ValueSome (Array.append widgetAttrs componentAttrs) + WidgetCollectionAttributes = + match struct (rootWidget.WidgetCollectionAttributes, widgetColls) with + | ValueNone, ValueNone -> ValueNone + | ValueSome attrs, ValueNone + | ValueNone, ValueSome attrs -> ValueSome attrs + | ValueSome widgetAttrs, ValueSome componentAttrs -> ValueSome (Array.append widgetAttrs componentAttrs) } + let struct (node, view) = widgetDef.CreateView(rootWidget, treeContext, ValueNone) _view <- view @@ -157,7 +186,7 @@ module Component = | None -> ComponentContext() let comp = new Component(treeContext, body, context) - let struct(node, view) = comp.CreateView() + let struct(node, view) = comp.CreateView(widget.ScalarAttributes, widget.WidgetAttributes, widget.WidgetCollectionAttributes) struct (node, view) } @@ -170,19 +199,7 @@ type ComponentModifiers = [] static member inline withContext(this: WidgetBuilder<'msg, 'marker>, context: ComponentContext) = this.AddScalar(Component.Context.WithValue(context)) - -[] -type ComponentExtensions = - [] - static member inline WithValue(this: SimpleScalarAttributeDefinition, [] value: ComponentBodyBuilder<'msg, 'marker>) = - let compiledBody = - ComponentBody(fun ctx -> - let widgetBuilder = value.Invoke(ctx) - widgetBuilder.Compile() - ) - - this.WithValue(compiledBody) - + type ViewBuilder() = member inline this.Yield(widget: WidgetBuilder<'msg, 'marker>) = ComponentBodyBuilder(fun ctx -> widget) @@ -201,9 +218,15 @@ type ViewBuilder() = ) member inline this.Run([] body: ComponentBodyBuilder<'msg, 'marker>) = + let compiledBody = + ComponentBody(fun ctx -> + let widgetBuilder = body.Invoke(ctx) + widgetBuilder.Compile() + ) + WidgetBuilder<'msg, 'marker>( Component.WidgetKey, - Component.Body.WithValue(body) + Component.Body.WithValue(compiledBody) ) From 332827d1c19ea4c428682f7eb59efed5d6efc861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9=20Larivi=C3=A8re?= Date: Wed, 22 Nov 2023 08:46:58 +0100 Subject: [PATCH 4/7] Split Component.fs file into several files --- src/Fabulous/Component.fs | 369 ---------------------------- src/Fabulous/Component/Binding.fs | 61 +++++ src/Fabulous/Component/Builder.fs | 48 ++++ src/Fabulous/Component/Component.fs | 366 +++++++++++++++++++++++++++ src/Fabulous/Component/Context.fs | 55 +++++ src/Fabulous/Component/State.fs | 84 +++++++ src/Fabulous/Fabulous.fsproj | 6 +- 7 files changed, 619 insertions(+), 370 deletions(-) delete mode 100644 src/Fabulous/Component.fs create mode 100644 src/Fabulous/Component/Binding.fs create mode 100644 src/Fabulous/Component/Builder.fs create mode 100644 src/Fabulous/Component/Component.fs create mode 100644 src/Fabulous/Component/Context.fs create mode 100644 src/Fabulous/Component/State.fs diff --git a/src/Fabulous/Component.fs b/src/Fabulous/Component.fs deleted file mode 100644 index 6803cac68..000000000 --- a/src/Fabulous/Component.fs +++ /dev/null @@ -1,369 +0,0 @@ -namespace Fabulous - -open System -open System.ComponentModel -open System.Runtime.CompilerServices -open System.Collections.Generic -open Fabulous.ScalarAttributeDefinitions - -////////////// CONTEXT ////////////// -type ComponentContext() = - let values = Dictionary() - let mutable _current = -1 - - let renderNeeded = Event() - member this.RenderNeeded = renderNeeded.Publish - member this.NeedsRender() = renderNeeded.Trigger() - - member this.MoveNext() = - _current <- _current + 1 - _current - - member this.Current - with get () = - match values.TryGetValue(_current) with - | false, _ -> ValueNone - | true, value -> ValueSome value - - member this.SetCurrentValue(value: 'T) = - values[_current] <- value - - member this.SetValue(key: int, value: 'T) = - values[key] <- value - this.NeedsRender() - - member this.AfterRender() = - _current <- -1 - - - -////////////// ViewBuilder ////////////// -type ComponentBody = delegate of ComponentContext -> Widget -type ComponentBodyBuilder<'msg, 'marker> = delegate of ComponentContext -> WidgetBuilder<'msg, 'marker> - - -////////////// Component holder ////////////// - -type Component(treeContext: ViewTreeContext, body: ComponentBody, context: ComponentContext) = - let mutable _body = body - let mutable _context = context - let mutable _widget = Unchecked.defaultof<_> - let mutable _view = null - let mutable _contextSubscription: IDisposable = null - - // TODO: This is a big code smell. We should not do this but I can't think of a better way to do it right now. - // The implementation of this method is set by the consuming project: Fabulous.XamarinForms, Fabulous.Maui, Fabulous.Avalonia - static let mutable _setAttachedComponent: obj -> Component -> unit = fun _ _ -> failwith "Please call Component.SetComponentFunctions() before using Component" - static let mutable _getAttachedComponent: obj -> Component = fun _ -> failwith "Please call Component.SetComponentFunctions() before using Component" - static member SetComponentFunctions(get: obj -> Component, set: obj -> Component -> unit) = - _getAttachedComponent <- get - _setAttachedComponent <- set - - static member GetAttachedComponent(view: obj) = _getAttachedComponent view - static member SetAttachedComponent(view: obj, comp: Component) = _setAttachedComponent view comp - - member this.Body - with get () = _body - and set value = - _body <- value - this.Render() - - member this.Context - with get () = _context - and set value = - if _contextSubscription <> null then - _contextSubscription.Dispose() - - _context <- value - - _contextSubscription <- this.Context.RenderNeeded.Subscribe(this.Render) - - this.Render() - - member this.CreateView(scalars: ScalarAttribute array voption, widgets: WidgetAttribute array voption, widgetColls: WidgetCollectionAttribute array voption) = - _contextSubscription <- this.Context.RenderNeeded.Subscribe(this.Render) - - let rootWidget = this.Body.Invoke(context) - _widget <- rootWidget - - this.Context.AfterRender() - - let widgetDef = WidgetDefinitionStore.get rootWidget.Key - - // Inject the attributes added to the component directly into the root widget - let scalars = - match scalars with - | ValueNone -> ValueNone - | ValueSome attrs -> ValueSome (Array.filter (fun (attr: ScalarAttribute) -> attr.DebugName <> "Component_Body" && attr.DebugName <> "Component_Context") attrs) - - let rootWidget: Widget = - { Key = rootWidget.Key - DebugName = rootWidget.DebugName - ScalarAttributes = - match struct (rootWidget.ScalarAttributes, scalars) with - | ValueNone, ValueNone -> ValueNone - | ValueSome attrs, ValueNone - | ValueNone, ValueSome attrs -> ValueSome attrs - | ValueSome widgetAttrs, ValueSome componentAttrs -> ValueSome (Array.append widgetAttrs componentAttrs) - WidgetAttributes = - match struct (rootWidget.WidgetAttributes, widgets) with - | ValueNone, ValueNone -> ValueNone - | ValueSome attrs, ValueNone - | ValueNone, ValueSome attrs -> ValueSome attrs - | ValueSome widgetAttrs, ValueSome componentAttrs -> ValueSome (Array.append widgetAttrs componentAttrs) - WidgetCollectionAttributes = - match struct (rootWidget.WidgetCollectionAttributes, widgetColls) with - | ValueNone, ValueNone -> ValueNone - | ValueSome attrs, ValueNone - | ValueNone, ValueSome attrs -> ValueSome attrs - | ValueSome widgetAttrs, ValueSome componentAttrs -> ValueSome (Array.append widgetAttrs componentAttrs) } - - let struct (node, view) = widgetDef.CreateView(rootWidget, treeContext, ValueNone) - _view <- view - - Component.SetAttachedComponent(view, this) - - struct (node, view) - - member this.Render() = - let prevRootWidget = _widget - let currRootWidget = this.Body.Invoke(context) - _widget <- currRootWidget - - this.Context.AfterRender() - - let viewNode = treeContext.GetViewNode _view - - Reconciler.update treeContext.CanReuseView (ValueSome prevRootWidget) currRootWidget viewNode - - interface IDisposable with - member this.Dispose() = - if _contextSubscription <> null then - _contextSubscription.Dispose() - _contextSubscription <- null - - - - -////////////// Component widget ////////////// -module Component = - /// TODO: This is actually broken. On every call of the parent, the body will be reassigned to the Component triggering a re-render because of the noCompare. - /// This is not what was expected. The body should actually be invalidated based on its context. - let Body: SimpleScalarAttributeDefinition = Attributes.defineSimpleScalar "Component_Body" ScalarAttributeComparers.noCompare (fun _ currOpt node -> - let target = Component.GetAttachedComponent(node.Target) - match currOpt with - | ValueNone -> failwith "Component widget must have a body" - | ValueSome curr -> target.Body <- curr - ) - let Context: SimpleScalarAttributeDefinition = Attributes.defineSimpleScalar "Component_Context" ScalarAttributeComparers.equalityCompare (fun _ currOpt node -> - let target = Component.GetAttachedComponent(node.Target) - match currOpt with - | ValueNone -> target.Context <- ComponentContext() - | ValueSome curr -> target.Context <- curr - ) - - let WidgetKey = - let key = WidgetDefinitionStore.getNextKey() - - let definition = - { Key = key - Name = "Component" - TargetType = typeof - AttachView = fun _ -> failwith "Component widget cannot be attached" - CreateView = - fun (widget, treeContext, _) -> - match widget.ScalarAttributes with - | ValueNone -> failwith "Component widget must have a body" - | ValueSome attrs -> - let body = - match Array.tryFind (fun (attr: ScalarAttribute) -> attr.Key = Body.Key) attrs with - | Some attr -> attr.Value :?> ComponentBody - | None -> failwith "Component widget must have a body" - - let context = - match Array.tryFind (fun (attr: ScalarAttribute) -> attr.Key = Context.Key) attrs with - | Some attr -> attr.Value :?> ComponentContext - | None -> ComponentContext() - - let comp = new Component(treeContext, body, context) - let struct(node, view) = comp.CreateView(widget.ScalarAttributes, widget.WidgetAttributes, widget.WidgetCollectionAttributes) - - struct (node, view) } - - WidgetDefinitionStore.set key definition - - key - -[] -type ComponentModifiers = - [] - static member inline withContext(this: WidgetBuilder<'msg, 'marker>, context: ComponentContext) = - this.AddScalar(Component.Context.WithValue(context)) - -type ViewBuilder() = - member inline this.Yield(widget: WidgetBuilder<'msg, 'marker>) = - ComponentBodyBuilder(fun ctx -> widget) - - member inline this.Combine([] a: ComponentBodyBuilder<'msg, 'marker>, [] b: ComponentBodyBuilder<'msg, 'marker>) = - ComponentBodyBuilder(fun ctx -> - let _ = a.Invoke(ctx) // discard the previous widget in the chain - let result = b.Invoke(ctx) - result - ) - - member inline this.Delay([] fn: unit -> ComponentBodyBuilder<'msg, 'marker>) = - ComponentBodyBuilder(fun ctx -> - let sub = fn() - sub.Invoke(ctx) - ) - - member inline this.Run([] body: ComponentBodyBuilder<'msg, 'marker>) = - let compiledBody = - ComponentBody(fun ctx -> - let widgetBuilder = body.Invoke(ctx) - widgetBuilder.Compile() - ) - - WidgetBuilder<'msg, 'marker>( - Component.WidgetKey, - Component.Body.WithValue(compiledBody) - ) - - -////////////// State ////////////// - -type StateRequest<'T> = delegate of unit -> 'T - -/// DESIGN: State<'T> is meant to be very short lived. -/// It is created on Bind (let!) and destroyed at the end of a single ViewBuilder CE execution. -/// 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. -/// -/// e.g. -/// -/// Button("Increment", fun () -> state.Set(state.Current + 1)) -/// -/// will become -/// -/// class Closure { -/// public State state; // Storing a struct on a class will allocate it on the heap -/// -/// public void Invoke() { -/// state.Set(state.Current + 1); -/// } -/// } -/// -/// class Program { -/// public void View() -/// { -/// var state = new State(...); -/// -/// // This will allocate both the closure and the state on the heap -/// // which the GC will have to clean up later -/// var closure = new Closure(state = state); -/// -/// return Button("Increment", closure); -/// } -/// } -/// -/// -/// The Set method is therefore marked inlinable to avoid creating a closure capturing State<'T> -/// Instead the closure will only capture Context (already a reference type), Key (int) and Current (can be consider to be obj). -/// The compiler will rewrite the lambda as follow: -/// Button("Increment", fun () -> ctx.SetValue(key, current + 1)) -/// -/// State<'T> is no longer involved in the closure and will be kept on the stack. -/// -/// One constraint of inlining is to have all used fields public: Context, Key, Current -/// But we don't wish to expose the Context and Key fields to the user, so we mark them as EditorBrowsable.Never -type [] State<'T>= - [] - val public Context: ComponentContext - - [] - val public Key: int - - val public Current: 'T - - new (ctx, key, value) = { Context = ctx; Key = key; Current = value } - - member inline this.Set(value: 'T) = - this.Context.SetValue(this.Key, value) - -[] -type StateExtensions = - [] - static member inline Bind(_: ViewBuilder, [] fn: StateRequest<'T>, [] continuation: State<'T> -> ComponentBodyBuilder<'msg, 'marker>) = - ComponentBodyBuilder<'msg, 'marker>(fun ctx -> - let key = ctx.MoveNext() - - let value = - match ctx.Current with - | ValueSome value -> unbox<'T> value - | ValueNone -> - let value = fn.Invoke() - ctx.SetCurrentValue(value) - value - - let state = State(ctx, key, value) - (continuation state).Invoke(ctx) - ) - -[] -module StateHelpers = - let inline state value = StateRequest(fun () -> value) - - -////////////// Binding ////////////// - -(* - -The idea of Binding is to listen to a State<'T> that is managed by another Context and be able to update it -while notifying the two Contexts involved (source and target) - -let child (count: BindingRequest) = - view { - let! boundCount = bind count - - Button($"Count is {boundCount.Value}", fun () -> boundCount.Set(boundCount.Value + 1)) - } - -let parent = - view { - let! count = state 0 - - VStack() { - Text($"Count is {count.Value}") - child (Binding.ofState count) - } - } - -*) - -type BindingRequest<'T> = delegate of unit -> State<'T> - -type [] Binding<'T> = - val public Context: ComponentContext - val public Source: State<'T> - - new (ctx, source) = { Context = ctx; Source = source } - - member inline this.Current = this.Source.Current - - member inline this.Set(value: 'T) = - this.Source.Set(value) - this.Context.NeedsRender() - -[] -type BindingExtensions = - [] - static member inline Bind(_: ViewBuilder, [] request: BindingRequest<'T>, [] continuation: Binding<'T> -> ComponentBodyBuilder<'msg, 'marker>) = - ComponentBodyBuilder(fun ctx -> - let source = request.Invoke() - let state = Binding<'T>(ctx, source) - (continuation state).Invoke(ctx) - ) - -[] -module BindingHelpers = - let inline ofState (source: State<'T>) = BindingRequest(fun () -> source) - let inline bind (binding: Binding<'T>) = binding \ No newline at end of file diff --git a/src/Fabulous/Component/Binding.fs b/src/Fabulous/Component/Binding.fs new file mode 100644 index 000000000..e2abb6c8f --- /dev/null +++ b/src/Fabulous/Component/Binding.fs @@ -0,0 +1,61 @@ +namespace Fabulous + +open System.Runtime.CompilerServices + +(* + +The idea of Binding is to listen to a State<'T> that is managed by another Context and be able to update it +while notifying the two Contexts involved (source and target) + +let child (count: BindingRequest) = + view { + let! boundCount = bind count + + Button($"Count is {boundCount.Value}", fun () -> boundCount.Set(boundCount.Value + 1)) + } + +let parent = + view { + let! count = state 0 + + VStack() { + Text($"Count is {count.Value}") + child (Binding.ofState count) + } + } + +*) + +type ComponentBindingRequest<'T> = delegate of unit -> ComponentState<'T> + +type [] ComponentBinding<'T> = + val public Context: ComponentContext + val public Source: ComponentState<'T> + + new (ctx, source) = { Context = ctx; Source = source } + + member inline this.Current = this.Source.Current + + member inline this.Set(value: 'T) = + this.Source.Set(value) + +[] +type BindingExtensions = + [] + static member inline Bind(_: ComponentBuilder, [] request: ComponentBindingRequest<'T>, [] continuation: ComponentBinding<'T> -> ComponentBodyBuilder<'msg, 'marker>) = + // Despite its name, ComponentBinding actual value is not stored in this component, but in the source component + // So, we do not need to increment the number of bindings here + ComponentBodyBuilder(fun bindings ctx -> + let source = request.Invoke() + + source.Context.RenderNeeded.Add(fun () -> ctx.NeedsRender()) + + let state = ComponentBinding<'T>(ctx, source) + (continuation state).Invoke(bindings, ctx) + ) + +[] +module BindingHelpers = + let inline ofState (source: ComponentState<'T>) = ComponentBindingRequest(fun () -> source) + let inline bind (binding: ComponentBinding<'T>) = binding + diff --git a/src/Fabulous/Component/Builder.fs b/src/Fabulous/Component/Builder.fs new file mode 100644 index 000000000..483aef291 --- /dev/null +++ b/src/Fabulous/Component/Builder.fs @@ -0,0 +1,48 @@ +namespace Fabulous + +/// Delegate used by the ComponentBuilder to compose a component body +/// It will be aggressively inlined by the compiler leaving no overhead, only a pure function that returns a WidgetBuilder +type ComponentBodyBuilder<'msg, 'marker> = delegate of bindings: int * context: ComponentContext -> struct (int * WidgetBuilder<'msg, 'marker>) + +type ComponentBuilder() = + member inline this.Yield(widgetBuilder: WidgetBuilder<'msg, 'marker>) = + ComponentBodyBuilder<'msg, 'marker>(fun bindings ctx -> + struct( + bindings, + widgetBuilder + ) + ) + + member inline this.Combine([] a: ComponentBodyBuilder<'msg, 'marker>, [] b: ComponentBodyBuilder<'msg, 'marker>) = + ComponentBodyBuilder<'msg, 'marker>(fun bindings ctx -> + let struct (bindingsA, _) = a.Invoke(bindings, ctx) // discard the previous widget in the chain but we still need to count the bindings + let struct (bindingsB, resultB) = b.Invoke(bindings, ctx) + + // Calculate the total number of bindings between A and B + let resultBindings = (bindingsA + bindingsB) - bindings + + struct (resultBindings, resultB) + ) + + member inline this.Delay([] fn: unit -> ComponentBodyBuilder<'msg, 'marker>) = + ComponentBodyBuilder<'msg, 'marker>(fun bindings ctx -> + let sub = fn() + sub.Invoke(bindings, ctx) + ) + + member inline this.Run([] body: ComponentBodyBuilder<'msg, 'marker>) = + let compiledBody = + ComponentBody(fun ctxOpt -> + let ctx = + match ctxOpt with + | ValueNone -> ComponentContext() + | ValueSome ctx -> ctx + + let struct (_, result) = body.Invoke(0, ctx) + struct (ctx, result.Compile()) + ) + + WidgetBuilder<'msg, 'marker>( + Component.WidgetKey, + Component.Body.WithValue(compiledBody) + ) \ No newline at end of file diff --git a/src/Fabulous/Component/Component.fs b/src/Fabulous/Component/Component.fs new file mode 100644 index 000000000..54c1cec40 --- /dev/null +++ b/src/Fabulous/Component/Component.fs @@ -0,0 +1,366 @@ +namespace Fabulous + +open System +open System.Runtime.CompilerServices +open Fabulous.ScalarAttributeDefinitions + +(* + +## What's going on here: +This is an attempt at making re-executable computation expressions with a context being passed implicitly. + +## History and constraints +Today in Fabulous, there is only one source of truth for the whole app: it's root state. + +Whenever a change happens in this root state, the whole view hierarchy is re-evaluated to check for any +UI update that needs to be applied on the screen. Having this single source of truth is great to ensure consistency, +but it implies a lot of unnecessary processing because 99% of the time a state change will only have an impact locally, +not globally, hence it would be better to only re-evaluate the local view hierarchy. + +This idea is known as "components": you can see them as some kind of mini-apps managing their own local state +that can trigger re-evaluation on their own and that can be composed together to make an actual Fabulous application. + +Despite quite a lot of prior arts (SwiftUI "View" protocol, React components, FuncUI components, Vide builders, etc.), +it has been difficult to come up with a component approach in Fabulous due to the unique set of constraints: mobile & F#. +While the implementation is straightforward in the other F# libraries (FuncUI, Vide), they make heavy use of closures +which allocate of lot of memory; something Fabulous cannot afford because GC would keep freezing the app +on lower end Android smartphones due to limited memory. Hence it is better to avoid closures and make heavy use +of structs instead of classes. + +Also another aspect why it has been difficult to come up with anything is the opinionated ergonomics wanted for Fabulous. +Fabulous took a similar approach to SwiftUI: a builder pattern with handcrafted widgets and modifiers. +But contrary to Swift, in .NET (C# & F#) using interfaces (protocols in Swift) over struct will result in boxing because +a struct first need to be transformed into an object before being casted to the interface. This triggers a lot of memory +allocation, which is what we want to avoid in the first place with the structs, so a different approach is required. + +type IComponent = interface end +type [] TextWidget(value: string) = interface IComponent + +let text = TextWidget("Hello") +let component = text :> IComponent // ----> let component = text >> box :> IComponent + +Another point we want to take a look into is the ability to use any kind of state management, not only MVU. + +With all those constraints in place, we want something that can easily be composed into Fabulous 2 DSL ergonomics, +lets you choose your own state management, and almost allocation-free to be friendly with low end mobile devices. + +This means we need to make heavy use of inlining and structs. +Computation expressions to the rescue. + +## Implementation ideas + +A component needs to somehow hold its own state and have a view description that can be evaluated at will everytime +the state changes. + +let component = + view { + let! count = state 0 + + VStack() { + Text($"Count is {count.Value}") + Button("Increment", fun () -> count.Set(count + 1)) + Button("Decrement", fun () -> count.Set(count - 1)) + } + } + +To achieve this, we can create a ViewBuilder computation expression that will store its body into a function. +The state is bound to variables by using `let!`. + + +builder.Run( + builder.Delay( + builder.Bind(state 0, fun count -> // this is for "let! count = state 0" + builder.Yield( // this is an implicit yield + VStack() { ... } + ) + ) + ) +) + +The ViewBuilder makes use of the implicit yield capability of F# by implementing: "Yield", "Combine", and "Delay". +Contrary to what the F# documentation states, "Zero" is not required to have implicit yield. + +- Yield: Widget -> Contextual +- Combine: [] Contextual * [] Contextual -> Contextual +- Delay: [] (unit -> Contextual) -> Contextual + +Contextual is a composable delegate that take a Context (so we can pass it implicitly around the CE, mainly to be used +in "Bind" without making it visible in the user code) and return a Widget, which is the typical body of a component. + +Why are we using a delegate here? +Delegates are basically lambdas, so combining this with inlined CE methods ("member inline Yield", etc.) and the attribute +[], we can flatten the whole body of the CE into a single Contextual lambda. + +Example: + +let result = + (fun () -> // the Delay + Contextual(fun ctx -> // the Bind + let count = ctx.GetState(0) + Contextual(fun ctx -> // the Yield + VStack() { + Text($"Count is {count.Value}") + } + ) + ) + )() + +will become + +let result = + Contextual(fun ctx -> + let count = ctx.GetState(0) + VStack() { + Text($"Count is {count.Value}") + } + ) + + +Since we already get a "Contextual" at every step of the CE, "Run" doesn't need any specific implementation except +returning the latest Contextual function. + +## How does state works and how everything gets re-evaluated on change + +"let! count = state 0" is a request to the implicit context passed around in the CE to retrieve the previous state value +or initialize it with the default value "0" + +inline state 0 // helper function to hide the default factory lambda +--> StateRequest(fun () -> 0) // StateRequest is also an inlinable delegate +--> let! === ctx.TryGetValue() or ctx.SetValue(0) +--> struct State(ctx, key, value) + +- static member inline Bind(_: ViewBuilder, [] request: StateRequest<'T>, [] continuation: State<'T> -> Contextual) + +Since we are passing the Context itself into the State struct value given to the user, when the user calls "count.Set(newValue)", +it will mark the context as dirty, meaning a re-evaluation is needed. + +This context is originated from the Component that hold both its own Context and the Contextual lambda created by the CE. +The Component listens to its context Dirtied event to know when to re-evaluate the body. + +Another important point is, the context uses positional indexes to store and retrieve the states. + +Say you have the following body: + +let body = + view { + let! firstName = state "George" + let! lastName = state "Roger" + (...) + } + +behind the scene, when calling Bind for firstName, Context will switch to the index 0. +Then when calling Bind for lastName, Context will switch to index 1. +Resulting in + +Context.values = +[0] = "George" +[1] = "Roger" + +On subsequent reevaluations, Context switch back to index 0 to retrieve the values in order +let firstName = Context.values[0] +let lastName = Context.values[1] + +This means conditional state is to be avoided + +DONT: +let body = + view { + if someCondition then + let! firstName = state "George" + Text("First name is {firstName.Value}") + else + let! lastName = state "Roger" + Text("Last name is {lastName.Value}") + } + +This will returning a confusing result. + +// 1st execution - someCondition = true +First name is George + +// 2nd execution - someCondition = false +Last name is George + +As a user, you expect to have two independent states: one for FirstName, one for LastName. +But since Context uses positional access to retrieve the values, firstName and lastName make no difference for Context +since they will both have Position = 0. + + +############### + +A nice thing about this approach is that we can share a context between several components. +This is useful is the context of controls repeated several times that actually represent a same thing +(eg the avatar picture in the chat message page that gets repeated in front of each message) + +let sharedContext = Context() + +let avatar1 = Component(sharedContext, Avatar()) +let avatar2 = Component(sharedContext, Avatar()) + +avatar1.Background <- Blue +// Automatically triggers avator2.Background to become Blue + +*) + + +type ComponentBody = delegate of ComponentContext voption -> struct(ComponentContext * Widget) + +type Component(treeContext: ViewTreeContext, body: ComponentBody) = + let mutable _body = body + let mutable _context = Unchecked.defaultof + let mutable _widget = Unchecked.defaultof<_> + let mutable _view = null + let mutable _contextSubscription: IDisposable = null + + // TODO: This is a big code smell. We should not do this but I can't think of a better way to do it right now. + // The implementation of this method is set by the consuming project: Fabulous.XamarinForms, Fabulous.Maui, Fabulous.Avalonia + static let mutable _setAttachedComponent: obj -> Component -> unit = fun _ _ -> failwith "Please call Component.SetComponentFunctions() before using Component" + static let mutable _getAttachedComponent: obj -> Component = fun _ -> failwith "Please call Component.SetComponentFunctions() before using Component" + static member SetComponentFunctions(get: obj -> Component, set: obj -> Component -> unit) = + _getAttachedComponent <- get + _setAttachedComponent <- set + + static member GetAttachedComponent(view: obj) = _getAttachedComponent view + static member SetAttachedComponent(view: obj, comp: Component) = _setAttachedComponent view comp + + member this.SetBody(body: ComponentBody) = + _body <- body + this.Render() + + member this.SetContext(context: ComponentContext) = + _contextSubscription.Dispose() + _contextSubscription <- context.RenderNeeded.Subscribe(this.Render) + _context <- context + this.Render() + + member this.CreateView(componentWidget: Widget, ?sharedContext: ComponentContext) = + let initialContext = + match sharedContext with + | None -> ValueNone + | Some ctx -> ValueSome ctx + + let struct (context, rootWidget) = _body.Invoke(initialContext) + _widget <- rootWidget + _context <- context + + // Inject the attributes added to the component directly into the root widget + let scalars = + match componentWidget.ScalarAttributes with + | ValueNone -> ValueNone + | ValueSome attrs -> ValueSome (Array.filter (fun (attr: ScalarAttribute) -> attr.DebugName <> "Component_Body" && attr.DebugName <> "Component_Context") attrs) + + let rootWidget: Widget = + { Key = rootWidget.Key + DebugName = rootWidget.DebugName + ScalarAttributes = + match struct (rootWidget.ScalarAttributes, scalars) with + | ValueNone, ValueNone -> ValueNone + | ValueSome attrs, ValueNone + | ValueNone, ValueSome attrs -> ValueSome attrs + | ValueSome widgetAttrs, ValueSome componentAttrs -> ValueSome (Array.append widgetAttrs componentAttrs) + WidgetAttributes = + match struct (rootWidget.WidgetAttributes, componentWidget.WidgetAttributes) with + | ValueNone, ValueNone -> ValueNone + | ValueSome attrs, ValueNone + | ValueNone, ValueSome attrs -> ValueSome attrs + | ValueSome widgetAttrs, ValueSome componentAttrs -> ValueSome (Array.append widgetAttrs componentAttrs) + WidgetCollectionAttributes = + match struct (rootWidget.WidgetCollectionAttributes, componentWidget.WidgetCollectionAttributes) with + | ValueNone, ValueNone -> ValueNone + | ValueSome attrs, ValueNone + | ValueNone, ValueSome attrs -> ValueSome attrs + | ValueSome widgetAttrs, ValueSome componentAttrs -> ValueSome (Array.append widgetAttrs componentAttrs) } + + // Create the actual view + let widgetDef = WidgetDefinitionStore.get rootWidget.Key + let struct (node, view) = widgetDef.CreateView(rootWidget, treeContext, ValueNone) + _view <- view + + Component.SetAttachedComponent(view, this) + + _contextSubscription <- _context.RenderNeeded.Subscribe(this.Render) + + struct (node, view) + + member this.Render() = + let prevRootWidget = _widget + let prevContext = _context + let struct (context, currRootWidget) = _body.Invoke(ValueSome _context) + _widget <- currRootWidget + + if prevContext <> context then + _contextSubscription.Dispose() + _contextSubscription <- context.RenderNeeded.Subscribe(this.Render) + _context <- context + + let viewNode = treeContext.GetViewNode _view + + Reconciler.update treeContext.CanReuseView (ValueSome prevRootWidget) currRootWidget viewNode + + interface IDisposable with + member this.Dispose() = + if _contextSubscription <> null then + _contextSubscription.Dispose() + _contextSubscription <- null + + + + +////////////// Component widget ////////////// +module Component = + /// TODO: This is actually broken. On every call of the parent, the body will be reassigned to the Component triggering a re-render because of the noCompare. + /// This is not what was expected. The body should actually be invalidated based on its context. + let Body: SimpleScalarAttributeDefinition = + Attributes.defineSimpleScalar + "Component_Body" + ScalarAttributeComparers.noCompare + (fun _ currOpt node -> + let target = Component.GetAttachedComponent(node.Target) + match currOpt with + | ValueNone -> failwith "Component widget must have a body" + | ValueSome body -> target.SetBody(body) + ) + let Context: SimpleScalarAttributeDefinition = Attributes.defineSimpleScalar "Component_Context" ScalarAttributeComparers.equalityCompare (fun _ currOpt node -> + let target = Component.GetAttachedComponent(node.Target) + match currOpt with + | ValueNone -> failwith "Component widget must have a body" + | ValueSome context -> target.SetContext(context) + ) + + let WidgetKey = + let key = WidgetDefinitionStore.getNextKey() + + let definition = + { Key = key + Name = "Component" + TargetType = typeof + AttachView = fun _ -> failwith "Component widget cannot be attached" + CreateView = + fun (widget, treeContext, _) -> + match widget.ScalarAttributes with + | ValueNone -> failwith "Component widget must have a body" + | ValueSome attrs -> + let body = + match Array.tryFind (fun (attr: ScalarAttribute) -> attr.Key = Body.Key) attrs with + | Some attr -> attr.Value :?> ComponentBody + | None -> failwith "Component widget must have a body" + + let context = + match Array.tryFind (fun (attr: ScalarAttribute) -> attr.Key = Context.Key) attrs with + | None -> None + | Some attr -> Some (attr.Value :?> ComponentContext) + + let comp = new Component(treeContext, body) + let struct(node, view) = comp.CreateView(widget, ?sharedContext = context) + + struct (node, view) } + + WidgetDefinitionStore.set key definition + + key + +[] +type ComponentModifiers = + [] + static member inline withContext(this: WidgetBuilder<'msg, 'marker>, context: ComponentContext) = + this.AddScalar(Component.Context.WithValue(context)) diff --git a/src/Fabulous/Component/Context.fs b/src/Fabulous/Component/Context.fs new file mode 100644 index 000000000..a615ef1c6 --- /dev/null +++ b/src/Fabulous/Component/Context.fs @@ -0,0 +1,55 @@ +namespace Fabulous + +(* +ARCHITECTURE NOTES: + +Conceptually, a ComponentContext is an array containing all the current values for each state inside the component. +Each state is associated with a index key that it can use to retrieve or update its value. + +The ComponentContext is meant to be attached to a Component instance, and passed implicitly in the body of the component +where it will be accessible through let! bindings. + +Given each state is assigned to a specific index and that Components will most likely have a fixed number of bindings, +we can leverage the inlining capabilities of the ComponentBuilder to create an array with the right size. +*) + +/// This measure type is used to count the number of bindings in a component while building the computation expression +type [] binding + +/// +/// Holds the values for the various states of a component. +/// +type ComponentContext() = + // We assume that most components will have few values, so initialize it with a small array + let mutable values = Array.zeroCreate 3 + + let renderNeeded = Event() + member this.RenderNeeded = renderNeeded.Publish + member this.NeedsRender() = renderNeeded.Trigger() + + member private this.ResizeIfNeeded(count: int) = + // If the array is already big enough, we don't need to do anything + // Otherwise, we create a new array and copy the values from the old one + // It is assumed the component will have a stable amount of values, so this should not happen often + if values.Length < count then + let newLength = max (values.Length * 2) count + let newArray = Array.zeroCreate newLength + Array.blit values 0 newArray 0 values.Length + values <- newArray + + member this.TryGetValue<'T>(key: int) = + this.ResizeIfNeeded(key + 1) + + let value = values[key] + if isNull value then + ValueNone + else + ValueSome (unbox<'T> value) + + member internal this.SetValueInternal(key: int, value: 'T) = + values[key] <- box value + + member this.SetValue(key: int, value: 'T) = + values[key] <- box value + this.NeedsRender() + diff --git a/src/Fabulous/Component/State.fs b/src/Fabulous/Component/State.fs new file mode 100644 index 000000000..ad669e033 --- /dev/null +++ b/src/Fabulous/Component/State.fs @@ -0,0 +1,84 @@ +namespace Fabulous + +open System.ComponentModel +open System.Runtime.CompilerServices + +type ComponentStateRequest<'T> = delegate of unit -> 'T + +/// DESIGN: State<'T> is meant to be very short lived. +/// It is created on Bind (let!) and destroyed at the end of a single ViewBuilder CE execution. +/// 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. +/// +/// e.g. +/// +/// Button("Increment", fun () -> state.Set(state.Current + 1)) +/// +/// will become +/// +/// class Closure { +/// public State state; // Storing a struct on a class will allocate it on the heap +/// +/// public void Invoke() { +/// state.Set(state.Current + 1); +/// } +/// } +/// +/// class Program { +/// public void View() +/// { +/// var state = new State(...); +/// +/// // This will allocate both the closure and the state on the heap +/// // which the GC will have to clean up later +/// var closure = new Closure(state = state); +/// +/// return Button("Increment", closure); +/// } +/// } +/// +/// +/// The Set method is therefore marked inlinable to avoid creating a closure capturing State<'T> +/// Instead the closure will only capture Context (already a reference type), Key (int) and Current (can be consider to be obj). +/// The compiler will rewrite the lambda as follow: +/// Button("Increment", fun () -> ctx.SetValue(key, current + 1)) +/// +/// State<'T> is no longer involved in the closure and will be kept on the stack. +/// +/// One constraint of inlining is to have all used fields public: Context, Key, Current +/// But we don't wish to expose the Context and Key fields to the user, so we mark them as EditorBrowsable.Never +type [] ComponentState<'T> = + [] + val public Context: ComponentContext + + [] + val public Key: int + + val public Current: 'T + + new (ctx, key, value) = { Context = ctx; Key = key; Current = value } + + member inline this.Set(value: 'T) = + this.Context.SetValue(this.Key, value) + +[] +type StateExtensions = + [] + static member inline Bind(_: ComponentBuilder, [] fn: ComponentStateRequest<'T>, [] continuation: ComponentState<'T> -> ComponentBodyBuilder<'msg, 'marker>) = + ComponentBodyBuilder<'msg, 'marker>(fun bindings ctx -> + let key = int bindings + let value = + match ctx.TryGetValue<'T>(key) with + | ValueSome value -> value + | ValueNone -> + let newValue = fn.Invoke() + ctx.SetValue(key, newValue) + newValue + + let state = ComponentState(ctx, key, value) + (continuation state).Invoke((bindings + 1), ctx) + ) + +[] +module StateHelpers = + let inline state value = ComponentStateRequest(fun () -> value) + diff --git a/src/Fabulous/Fabulous.fsproj b/src/Fabulous/Fabulous.fsproj index 7b13b1479..615134e71 100644 --- a/src/Fabulous/Fabulous.fsproj +++ b/src/Fabulous/Fabulous.fsproj @@ -40,9 +40,13 @@ - + + + + +