JordanMarr / ReactiveElmish.Avalonia

Static Avalonia views for Elmish programs
Other
94 stars 8 forks source link

adding LiveCharts2 to a view (RenderLoop error?) #7

Closed houstonhaynes closed 1 year ago

houstonhaynes commented 1 year ago

I have forked this repo and am trying to add a chart based on the (C#) sample on the LiveCharts2 site.

I've done my best to muddle through (with some support on the F# Discord) and can get things to "compile" - BUT the reason for the quotes is that when I run Debug I get an unhandled exception:

Unhandled exception. System.MissingMethodException: Method not found: 'Void Avalonia.Rendering.RenderLoop..ctor()'.
   at Avalonia.Native.AvaloniaNativePlatform.DoInitialize(AvaloniaNativePlatformOptions options)
   at Avalonia.Native.AvaloniaNativePlatform.Initialize(IntPtr factory, AvaloniaNativePlatformOptions options) in /_/src/Avalonia.Native/AvaloniaNativePlatform.cs:line 32
   at Avalonia.Native.AvaloniaNativePlatform.Initialize(AvaloniaNativePlatformOptions options) in /_/src/Avalonia.Native/AvaloniaNativePlatform.cs:line 55
   at Avalonia.AvaloniaNativePlatformExtensions.<>c__DisplayClass0_0.<UseAvaloniaNative>b__0() in /_/src/Avalonia.Native/AvaloniaNativePlatformExtensions.cs:line 13
   at Avalonia.AppBuilder.SetupUnsafe() in /_/src/Avalonia.Controls/AppBuilder.cs:line 303
   at Avalonia.AppBuilder.Setup() in /_/src/Avalonia.Controls/AppBuilder.cs:line 291
   at Avalonia.AppBuilder.SetupWithLifetime(IApplicationLifetime lifetime) in /_/src/Avalonia.Controls/AppBuilder.cs:line 199
   at Avalonia.ClassicDesktopStyleApplicationLifetimeExtensions.StartWithClassicDesktopLifetime(AppBuilder builder, String[] args, ShutdownMode shutdownMode) in /_/src/Avalonia.Controls/ApplicationLifetimes/ClassicDesktopStyleApplicationLifetime.cs:line 219
   at AvaloniaExample.Program.main(String[] argv) in /Users/h3/repos/Elmish.Avalonia/src/Samples/AvaloniaExample/Program.fs:line 20

Process finished with exit code 134.

This is my first try at this so I'm casting around for any help I can find. Thanks.

houstonhaynes commented 1 year ago

FWIW I'm on MacOS M1 on the latest version of Rider. (2023.1.3)

        <PackageReference Include="System.Reactive.Core" Version="5.0.0" />
        <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.0" />
        <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0-preview5" />
        <PackageReference Include="Avalonia.Desktop" Version="11.0.0-preview5" />
        <PackageReference Include="Avalonia.Diagnostics" Version="11.0.0-preview5" />
        <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-preview5" />
        <PackageReference Include="LiveChartsCore" Version="2.0.0-beta.801" />
        <PackageReference Include="LiveChartsCore.SkiaSharpView" Version="2.0.0-beta.801" />
        <PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" Version="2.0.0-beta.800-11.0.0-rc1.1" />
        <ProjectReference Include="..\..\Elmish.Avalonia\Elmish.Avalonia.fsproj" />
houstonhaynes commented 1 year ago

Oh - and one more addendum - the LiveCharts2 sample app (C#) runs without issue using the same System, CommunityToolkit, Avalonia and LiveChartsCore versions

JordanMarr commented 1 year ago

It looks like the LiveCharts package you are using references a newer version of Avalonia (rc-1), so I had to updated the references to the latest:

    <ItemGroup>
        <PackageReference Include="System.Reactive.Core" Version="5.0.0" />
        <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.0" />
        <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0-rc1.1" />
        <PackageReference Include="Avalonia.Desktop" Version="11.0.0-rc1.1" />
        <PackageReference Include="Avalonia.Diagnostics" Version="11.0.0-rc1.1" />
        <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-rc1.1" />
        <PackageReference Include="LiveChartsCore" Version="2.0.0-beta.801" />
        <PackageReference Include="LiveChartsCore.SkiaSharpView" Version="2.0.0-beta.801" />
        <PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" Version="2.0.0-beta.800-11.0.0-rc1.1" />
        <ProjectReference Include="..\..\Elmish.Avalonia\Elmish.Avalonia.fsproj" />
        <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
    </ItemGroup>

Next fix was that your MainViewModel.fs ShowChart was pointing to the wrong page/vm, so I fixed that as well:

let update (msg: Msg) (model: Model) = 
    match msg with
    | ShowCounter -> 
        { model with ContentVM = CounterViewModel.vm }  
    | ShowChart -> 
        { model with ContentVM = ChartViewModel.vm }
    | ShowAbout ->
        { model with ContentVM = AboutViewModel.vm }

Next was that upgrading all the packages seemed to break the DataGrid control used on the CounterView page, but that's a different problem for a different day, so I just commented out the DataGrid to bypass the error:

        <!--<DataGrid Items="{Binding Actions}" AutoGenerateColumns="True" Height="400">            
        </DataGrid>-->

That should get you beyond the build error so you can continue playing around with the chart page.

JordanMarr commented 1 year ago

Here are the rest of the steps:

<lvc:CartesianChart 
    Grid.Row="1" 
    MinHeight="300"
    MinWidth="600"
    Series="{Binding Series}">
</lvc:CartesianChart>

I added a Series binding with some static data just to see it render:

let series = 
    let lineSeries = LineSeries<int>(Values = [1 .. 10], Fill = null, Name = "Income") :> ISeries
    [|
        lineSeries
    |]

let bindings ()  : Binding<Model, Msg> list = [
    "Actions" |> Binding.oneWay (fun m -> m.Actions)
    "AddItem" |> Binding.cmd AddItem
    "RemoveItem" |> Binding.cmd RemoveItem
    "UpdateItem" |> Binding.cmd UpdateItem
    "ReplaceItem" |> Binding.cmd ReplaceItem
    "Series" |> Binding.oneWay (fun m -> series)
]

image

JordanMarr commented 1 year ago

What you were trying to do with the MVVM bindings... you can probably mix and match MVVM + Elmish, but I would be inclined to just use Elmish rather than mix paradigms.

I made sure this framework is updated to use the latest Elmish v4, so you should be able to use its new subscriptions feature to subscribe to the observable and push the new value into model.Series or something like that.

But if you really want to use MVVM, you will need to use a VM class with property members for the RelayCommands, not a module w/ let bindings.

JordanMarr commented 1 year ago

Sidestepping the whole observable thing (because I'm not sure what you need to do), here is a very simple version that adds random numbers:

ChartViewModel.fs

module AvaloniaExample.ViewModels.ChartViewModel

open System
open System.Collections.ObjectModel
open CommunityToolkit.Mvvm.Input
open Elmish.Avalonia
open LiveChartsCore
open LiveChartsCore.Defaults
open LiveChartsCore.SkiaSharpView

let _random = Random()

type Model = 
    {
        Data: int list
    }

type Msg = 
    | AddItem

let init() = 
    { 
        Data = []
    }

let update (msg: Msg) (model: Model) = 
    match msg with
    | AddItem ->
        { model with 
            Actions = model.Actions @ [ { Description = "AddItem" } ]            
            Data = model.Data @ [ _random.Next(0, 10) ]
        }

let bindings ()  : Binding<Model, Msg> list = [
    "AddItem" |> Binding.cmd AddItem
    "Series" |> Binding.oneWay (fun m -> 
        [|
            LineSeries<int>(Values = m.Data, Fill = null, Name = "Income") :> ISeries
        |]
    )
]

let designVM = ViewModel.designInstance (init()) (bindings())

let vm = ElmishViewModel(AvaloniaProgram.mkSimple init update bindings)

Make sure you update your Button Command binding to AddItem (not AddItemCommand) so it matches your binding:

<Button Margin="6" Command="{Binding AddItem}">Add</Button>

image

houstonhaynes commented 1 year ago

Thanks so much for all of this - quite an education - some of which I'm still absorbing. I have to admit that it was a little confusing to see an example with a "ViewModel" folder in what I thought would be an MVU structured project. So, for much of the time I was wondering if there was room in "Elmish" for MVVM.

So while I'm here - I've got to say that still confuses me a bit - why does the Elmish.Avalonia sample project have a "ViewModels" folder? What am I missing about this sample?

[FYI - my use of ObservableCollection was because It was in the LiveChart2 sample and I was modeling that code in F# (or that was the idea). I also use Observables so frequently with WildernessLabs Meadow it struck me as a mental hand-hold that I thought would be useful.]

JordanMarr commented 1 year ago

I guess it could be a misnomer to call them view models, but this is sort of as hybrid model that uses MVU to create a view model.

The observable is certainly doable, but maybe not worth the hassle unless you really need it.

JordanMarr commented 1 year ago

I should also add that you can have one big MVU loop if you want. I just prefer to have a localized MVU loop per view.

houstonhaynes commented 1 year ago

I agree 100% - the strongest reason for using the ObservableCollection was the "mechanical sympathy" with the C# example on the LiveCharts2 site - though they did mention that other methods were possible.

That opens up the can-o-worms on "just use functional" versus the constructor method - it's a cognitive burden thing - if a new user (like myself) can't determine what's just a convenience to the author versus what could otherwise be a more generalizable pattern... you get the point I'm sure - since you've been moving in and out of these frameworks for much longer. I've been reading "Stylish F#" and have been thinking about it as I go from app-level aping of other people's work to actually internalizing F# "intent" for the lack of a better term. (since "idiom" is in the eye of the beholder)

I like the idea of an MVU loop per view. I've also been thinking about breaking down the "code by folder" approach that's baked in to C#. I plan to blog about this at some point - more for notes for myself - but also for those who might be similarly unfamiliar with the variety of patterns out there - to help newbs like myself make sense of it as "conventions" emerge.

houstonhaynes commented 1 year ago

Sorry - spoke too soon - I'm still not seeing things work - same error.

How "picky" is this on MSBuild versions? I have three refs (.NET 6, 7, 8) and it (Rider on Mac) seems to want to use 8's MSBuild for some weird reason.

The good news - is that when I updated Avalonia.Themes.Fluent to rc1 the preview in Rider was rendering... 👍

JordanMarr commented 1 year ago

It looks like you skipped the first step (which happens to be the step that fixes your bug), which is to update the NuGet packages. The Graph package you are using targets the newer release candidate version of Avalonia. I posted all the NuGet packages above so you can just copy/paste them into your project file exactly I as I have them.

Also, you still need to rename your button command bindings to match the bindings in your VM.

houstonhaynes commented 1 year ago

Dang - thanks - I thought I had gotten all of them. Copy-paste it is!

houstonhaynes commented 1 year ago

Got it working - had to add Actions back to your one-pager. Really nice, tight layout. Fun! 🥇

module AvaloniaExample.ViewModels.ChartViewModel

open System
open Elmish.Avalonia
open LiveChartsCore
open LiveChartsCore.SkiaSharpView

let _random = Random()

type Model = 
    {
        Data: int list
        Actions: Action list
    }

and Action = 
    {
        Description: string
    }

type Msg = 
    | AddItem

let init() = 
    { 
        Data = []
        Actions = [ { Description = "Init" } ]
    }

let update (msg: Msg) (model: Model) = 
    match msg with
    | AddItem ->
        { model with 
            Actions = model.Actions @ [ { Description = "AddItem" } ]            
            Data = model.Data @ [ _random.Next(0, 10) ]
        }

let bindings ()  : Binding<Model, Msg> list = [
    "AddItem" |> Binding.cmd AddItem
    "Series" |> Binding.oneWay (fun m -> 
        [|
            LineSeries<int>(Values = m.Data, Fill = null, Name = "Income") :> ISeries
        |]
    )
]

let designVM = ViewModel.designInstance (init()) (bindings())

let vm = ElmishViewModel(AvaloniaProgram.mkSimple init update bindings)
JordanMarr commented 1 year ago

It does look very clean!

houstonhaynes commented 1 year ago

And FWIW I think I found the reason why using ObservableCollection is "better" than just using a list... I think - maybe you can weigh in on this.

When you "update" in the current implementation you're really dropping in an entirely new list - which means the Series re-draws. (animates from the "bottom" X axis upward) But what I'm looking for is what LiveCharts2 refers to as AutoUpdate or as marked in the on-screen control - "Constant Updates" https://youtu.be/cwImMqLgAxg?t=2216

https://github.com/beto-rodriguez/LiveCharts2/blob/master/samples/AvaloniaSample/Lines/AutoUpdate/View.axaml.cs

The way that I read it, it's much like the virtual DOM in Blazor, where LiveCharts "sees" the change and performs the update without a full redraw - and manages the transition between the "current" and "next" variation of the set.

JordanMarr commented 1 year ago

TBH, you may want to use a classic view model class for this page with the live chart. I think the immutable model approach isn't the best fit here. However, you could maybe hack it by storing the chart data in a mutable variable like this (but I'm not 100% sure if this will accomplish what you are trying to do):

module AvaloniaExample.ViewModels.ChartViewModel

open System
open Elmish.Avalonia
open LiveChartsCore
open LiveChartsCore.SkiaSharpView
open System.Collections.ObjectModel
open LiveChartsCore.Defaults

let _random = Random()

type Model = 
    {
        Data: int list
        Actions: Action list
    }

and Action = 
    {
        Description: string
    }

type Msg = 
    | AddItem
    | RemoveItem
    | UpdateItem
    | ReplaceItem

let mutable series : ObservableCollection<ISeries> = null
let mutable observableValues : ObservableCollection<ObservablePoint> = null

let init() = 
    observableValues <- ObservableCollection<ObservablePoint>()
    series <- 
        ObservableCollection<ISeries> 
            [ 
                ColumnSeries<ObservablePoint>(Values = observableValues) :> ISeries 
            ] 

    { 
        Data = []
        Actions = [ { Description = "AddItem"} ]
    }

let update (msg: Msg) (model: Model) = 
    match msg with
    | AddItem ->
        observableValues.Add(ObservablePoint(_random.Next(0, 10), _random.Next(0, 10)))
        { model with 
            Actions = model.Actions @ [ { Description = "AddItem" } ]            
            Data = model.Data @ [ _random.Next(0, 10) ]
        }
    | RemoveItem ->
        { model with 
            Actions = model.Actions @ [ { Description = "RemoveItem" } ]            
            Data = model.Data |> List.rev |> List.tail |> List.rev
        }
    | UpdateItem ->
        { model with 
            Actions = model.Actions @ [ { Description = "UpdateItem" } ]            
            Data = model.Data |> List.rev |> List.tail |> List.rev |> List.map (fun x -> x + 1)
        }
    | ReplaceItem ->
        { model with 
            Actions = model.Actions @ [ { Description = "ReplaceItem" } ]            
            Data = model.Data |> List.rev |> List.tail |> List.rev |> List.map (fun x -> _random.Next(0, 10))
        }

let bindings ()  : Binding<Model, Msg> list = [
    "Actions" |> Binding.oneWay (fun m -> m.Actions)
    "AddItem" |> Binding.cmd AddItem
    "RemoveItem" |> Binding.cmd RemoveItem
    "UpdateItem" |> Binding.cmd UpdateItem
    "ReplaceItem" |> Binding.cmd ReplaceItem
    "Series" |> Binding.oneWay (fun m -> series)
]

let designVM = ViewModel.designInstance (init()) (bindings())

let vm = ElmishViewModel(AvaloniaProgram.mkSimple init update bindings)
houstonhaynes commented 1 year ago

Thanks - I had something similar. It's interesting that Adrii Chebukin said that a mutable ObservableCollection is not necessary. I'm accustomed to making an explicit declaration it seems weird (and obfuscating) not to do so as a reflex.

JordanMarr commented 1 year ago

You could try using this binding to guarantee that the "Series" binding won't ever be replaced (since it is mutable):

"Series" |> Binding.oneWayLazy ((fun m -> series), (fun a b -> true), id)

The second function fun a b -> true is an equality test to see if "Series" has changed, and we always return true, so it should keep reusing the same "Series" value.

But it looks the same to me either way. The graph doesn't appear to be resetting regardless of which binding is used. 🤷‍♂️

houstonhaynes commented 1 year ago

This is starting to be REALLY fun... I thought the way to skirt the render of the bar chart/histogram was to change to ObservableCollection<ObservableValue> [instead of ObservablePoint]

I'm looking forward to adding a timestamp to the Y axis, which is my next little bit of "flair" so getting that early look into using the ObservablePoint was a nice brain teaser. 😸

houstonhaynes commented 1 year ago

I see what you mean about the oneWayLazy binding - sort of - those first two functions are unused but the signature fails without the "unused" entries. That's something for me to understand more deeply. Screenshot 2023-06-26 at 11 14 15 AM

JordanMarr commented 1 year ago

It is definitely using the first two functions. The linting message is just pointing out that we are ignoring the m parameter (which we are since we're doing something hacky). But you can fix that by discarding the params:

"Series" |> Binding.oneWayLazy ((fun _ -> series), (fun _ _ -> true), id)
JordanMarr commented 1 year ago

It also occurred to me that we can simply store it on the Model. No need to put it in the ugly mutable value. It doesn't matter that the model is recreated each time as we are taking care to not replace the actual chart data, and we are using the oneWayLazy binding to ensure that the same object is used.

module AvaloniaExample.ViewModels.ChartViewModel

open System
open Elmish.Avalonia
open LiveChartsCore
open LiveChartsCore.SkiaSharpView
open System.Collections.ObjectModel
open LiveChartsCore.Defaults

let _random = Random()

type Model = 
    {
        Series: ObservableCollection<ISeries>
        Actions: Action list
    }

and Action = 
    {
        Description: string
    }

type Msg = 
    | AddItem
    | RemoveItem
    | UpdateItem
    | ReplaceItem

let init() = 
    { 
        Series = 
            ObservableCollection<ISeries> 
                [ 
                    LineSeries<ObservablePoint>(Values = ObservableCollection<ObservablePoint>()) :> ISeries 
                ] 
        Actions = [ { Description = "AddItem"} ]
    }

let update (msg: Msg) (model: Model) = 
    match msg with
    | AddItem ->
        let values = model.Series[0].Values :?> ObservableCollection<ObservablePoint>
        let len = values.Count
        values.Add(ObservablePoint(values.Count, _random.Next(1, 10)))
        { model with Actions = model.Actions @ [ { Description = "AddItem" } ] }
    | RemoveItem ->
        { model with Actions = model.Actions @ [ { Description = "RemoveItem" } ] }
    | UpdateItem ->
        { model with Actions = model.Actions @ [ { Description = "UpdateItem" } ] }
    | ReplaceItem ->
        let values = model.Series[0].Values :?> ObservableCollection<ObservablePoint>
        let fstValue = values[0]
        values[0] <- ObservablePoint(fstValue.X, 10)
        { model with Actions = model.Actions @ [ { Description = "ReplaceItem" } ] }

let bindings ()  : Binding<Model, Msg> list = [
    "Actions" |> Binding.oneWay (fun m -> m.Actions)
    "AddItem" |> Binding.cmd AddItem
    "RemoveItem" |> Binding.cmd RemoveItem
    "UpdateItem" |> Binding.cmd UpdateItem
    "ReplaceItem" |> Binding.cmd ReplaceItem
    "Series" |> Binding.oneWayLazy ((fun m -> m.Series), (fun _ _ -> false), id)
]

let designVM = ViewModel.designInstance (init()) (bindings())

let vm = ElmishViewModel(AvaloniaProgram.mkSimple init update bindings)
houstonhaynes commented 1 year ago

Thanks - I was actually wondering about that as I was "outside" the Model and wondering if that was "canonical" for the lack of a better term.

houstonhaynes commented 1 year ago

Alright - I'm about to let you off the hook - but there's just ONE more thing... 😸

I have set a recursive loop in the update - which might be a big no-no... since things have kind of gone sideways. But I thought I'd show you what I've done to try to ape the C# version of the "isStreaming" method.

Of course first I'd like to know just how wrong this is, and secondly I think this broke my build - as now I'm getting a bunch of errors that don't make any sense at all (like LiveChartsCore is not defined even though it's clearly part of the project)

let update (msg: Msg) (model: Model) =
    // new section
    let mutable isContinuing = false
    let runContinuous () =
        async {
            let rec streamingLoop () =
                if isContinuing then
                    let values = model.Series.[0].Values :?> ObservableCollection<ObservableValue>
                    values.RemoveAt(0)
                    values.Add(ObservableValue(_random.Next(1, 11)))
                    Task.Delay(1000) |> Async.AwaitTask |> ignore
                    streamingLoop ()

            streamingLoop ()
        }
    runContinuous () |> Async.StartImmediate
    // end new section

    match msg with
    | AddItem ->
        let values = model.Series[0].Values :?> ObservableCollection<ObservableValue>
        values.Add(ObservableValue(_random.Next(1, 11)))
        { model with 
            Actions = model.Actions @ [ { Description = "AddItem" } ]    
        }
    | RemoveItem ->
        let values = model.Series[0].Values :?> ObservableCollection<ObservableValue>
        values.RemoveAt(0)
        { model with 
            Actions = model.Actions @ [ { Description = "RemoveItem" } ]    
        }
    | UpdateItem ->
        let values = model.Series[0].Values :?> ObservableCollection<ObservableValue>
        let len = values.Count
        let item = _random.Next(0, len)
        values[item] <- ObservableValue(_random.Next(1, 11))
        { model with 
            Actions = model.Actions @ [ { Description = "UpdateItem" } ]            
        }
    | ReplaceItem ->
        let values = model.Series[0].Values :?> ObservableCollection<ObservableValue>
        let lstValue = values.Count-1
        values[lstValue] <- ObservableValue(_random.Next(1, 11))
        { model with 
            Actions = model.Actions @ [ { Description = "ReplaceItem" } ]            
        }
    | Continue ->
        match isContinuing with
            | false ->
                isContinuing <- true
                { model with 
                    Actions = model.Actions @ [ { Description = "Continue" } ]            
                }
            | _ ->
                isContinuing <- false
                { model with 
                    Actions = model.Actions @ [ { Description = "Continue" } ]            
                } 

and here's what I'm trying to bring over from C#

https://github.com/beto-rodriguez/LiveCharts2/blob/master/samples/AvaloniaSample/Lines/AutoUpdate/View.axaml.cs

Thanks!

JordanMarr commented 1 year ago

The problem is that you have introduced an effect into update function which should be pure. You should instead pull the side effect out of the update fn via the Elmish subscription system.

You will like this:

module AvaloniaExample.ViewModels.ChartViewModel

open System
open Elmish.Avalonia
open LiveChartsCore
open LiveChartsCore.SkiaSharpView
open System.Collections.ObjectModel
open LiveChartsCore.Defaults

let _random = Random()

type Model = 
    {
        Series: ObservableCollection<ISeries>
        Actions: Action list
    }

and Action = 
    {
        Description: string
    }

type Msg = 
    | AddItem
    | RemoveItem
    | UpdateItem
    | ReplaceItem
    | Reset

let init() = 
    { 
        Series = 
            ObservableCollection<ISeries> 
                [ 
                    LineSeries<ObservablePoint>(Values = ObservableCollection<ObservablePoint>()) :> ISeries 
                ] 
        Actions = [ { Description = "AddItem"} ]
    }

let update (msg: Msg) (model: Model) = 
    match msg with
    | AddItem ->
        let values = model.Series[0].Values :?> ObservableCollection<ObservablePoint>
        values.Add(ObservablePoint(values.Count, _random.Next(1, 10)))
        { model with Actions = model.Actions @ [ { Description = "AddItem" } ] }
    | RemoveItem ->
        { model with Actions = model.Actions @ [ { Description = "RemoveItem" } ] }
    | UpdateItem ->
        { model with Actions = model.Actions @ [ { Description = "UpdateItem" } ] }
    | ReplaceItem ->
        let values = model.Series[0].Values :?> ObservableCollection<ObservablePoint>
        let fstValue = values[0]
        values[0] <- ObservablePoint(fstValue.X, 10)
        { model with Actions = model.Actions @ [ { Description = "ReplaceItem" } ] }
    | Reset -> 
        let values = model.Series[0].Values :?> ObservableCollection<ObservablePoint>
        { model with Actions = model.Actions @ [ { Description = "Reset" } ] }

let bindings ()  : Binding<Model, Msg> list = [
    "Actions" |> Binding.oneWay (fun m -> m.Actions)
    "AddItem" |> Binding.cmd AddItem
    "RemoveItem" |> Binding.cmd RemoveItem
    "UpdateItem" |> Binding.cmd UpdateItem
    "ReplaceItem" |> Binding.cmd ReplaceItem
    "Series" |> Binding.oneWayLazy ((fun m -> m.Series), (fun _ _ -> false), id)
]

let designVM = ViewModel.designInstance (init()) (bindings())

open Elmish
open System.Timers

let subscriptions (model: Model) : Sub<Msg> =

    let valueChangedSubscription (dispatch: Msg -> unit) = 
        let timer = new Timer(500) // Fire twice per second
        timer.Elapsed.Add(fun _ -> 
            dispatch AddItem
        )
        timer.Start()
        timer :> IDisposable

    [
        [ nameof valueChangedSubscription ], valueChangedSubscription
    ]

let vm = ElmishViewModel(
    AvaloniaProgram.mkSimple init update bindings
    |> AvaloniaProgram.withSubscription subscriptions
)
houstonhaynes commented 1 year ago

LOVELY! I do likely... It kinda reminds me of the subscription model in WildernessLabs

I particularly like the dispatch. Very tidy!

Thanks so much!

houstonhaynes commented 1 year ago

You'll be proud of me 😆

I switched over from ObservableCollection<ObservableValue> to <DateTimePoint> and managed to bind the XAxes definition without having to come up for air to ask you! 🦜 😁

The "odd" thing I ran into was that the add/remove/updates where "backward" and creating an odd behavior.

Screenshot 2023-06-27 at 10 07 24 AM

What I figured out after a hot minute ⏲️ was that I had initialized the series in ascending order (following the index value I used to create the DateTimeOffset) and the chart was dutifully re-ordering according to the XAxis. That meant when I RemoveAt[0] it was getting right of the last in the series visually (because of the DateTime order). I laughed pretty hard at myself when I finally figured out to switch from

for i from 0 .. 10 do

to

for i = 10 downto 0 do

then everything "just worked" 👍 🙄

Thanks again - I realize this was a pretty hefty investment on your part, but I hope to pay it forward by both writing about this in my yet-to-be-published blog - FSharpNotes and perhaps add a video or two to go with it as the project continues.

JordanMarr commented 1 year ago

Super cool! It looks really good with the labels.

Small thing, but this line:

let newSeries =

should probably be changed to a function so that it is always reinitialized when it is called from the init function:

let newSeries () =
houstonhaynes commented 1 year ago

THERE IT IS! I knew I had missed something.

BTW - I want to grab the toggleButton and set IsChecked <- false but "cheat" my way through with a simple

let toggleButton = parentControl.FindControl("AutoUpdate") :?> ToggleButton

Screenshot 2023-06-27 at 6 46 53 PM

I'm getting "lit up" but not really certain what the "parent control" actually means in the User Control context. Is it looking for an overall User Control "Name" reference? Why can't we just use a "this" value? (like - I'm looking here why don't you infer that?)

daz10000 commented 1 year ago

I've been lurking here and enjoying the journey. If you're both up for it, this would be a great addition to the samples when its complete.

houstonhaynes commented 1 year ago

Thanks! I was wondering about building something that folks just "just build and run" without having to dig through the original project structure. Now that Elmish.Avalonia is available on nuget I think that's a big step forward.

I had also thought about fleshing out LiveChart2's sample app in this model - just as an exercise to create an F# equivalent. They have separated out some partial classes, which makes sense for their re-use in various examples from the same base visual - but I'm not sure that really suits the "garden path" metaphor I think would be useful to new folks here.

And I like the idea of an Elmish.Avalonia template as a first bookend for the garden path experience. Since this still an alpha I'm sure @JordanMarr already has his own ideas. 🧠 😁

daz10000 commented 1 year ago

On Wed, Jun 28, 2023 at 04:45:50AM -0700, Houston Haynes wrote:

Thanks! I was wondering about building something that folks just "just build and run" without having to dig through the original project structure. Now that Elmish.Avalonia is available on nuget I think that's a big step forward.

Not a strong push here, but my vote would still be keep it with the project code. I run into so many beautiful beautiful open source projects with minimal docs (which I totally understand, it's hard work to write / maintain docs), and often the docs aren't super helpful. A fully worked example is golden. Most productive coding is copying and hacking a working example. Once you're super familar with a library, then the template and an empty page is good, but the number of times I would have killed for just any example of X in a project (I filed a bug report recently on a feature in another library that I just couldn't get working after days and concluded was actually buggy - I couldn't find a single example anywhere on the internet or code base of it being used. Finally found another bug report, and then realized it had never worked. A single positive example with the code would have been an existence proof. Slightly more work for maintainer, but you can also build the demo projects with CI and they offer a smoke test that the library still works with "real code". I'll stop here, but I love the samples folder in a project..

JordanMarr commented 1 year ago

THERE IT IS! I knew I had missed something.

BTW - I want to grab the toggleButton and set IsChecked <- false but "cheat" my way through with a simple

let toggleButton = parentControl.FindControl("AutoUpdate") :?> ToggleButton

Screenshot 2023-06-27 at 6 46 53 PM

I'm getting "lit up" but not really certain what the "parent control" actually means in the User Control context. Is it looking for an overall User Control "Name" reference? Why can't we just use a "this" value? (like - I'm looking here why don't you infer that?)

You definitely want to avoid hacking the xaml control hierarchy in your MVU code. (If you have to resort to that, it would be better to do it in the code-behind file.)

If you are trying to enable/disable buttons that are bound to a command (Add, Remove, etc), you can use Binding.cmdIf:

"AddItem" |> Binding.cmdIf (fun m -> if m.Actions.Length > 5 then Some AddItem else None)

Alternatively, you could create a CanAddItem binding and manually bind it to the button:

<Button Margin="6" IsEnabled="{Binding CanAddItem}" Command="{Binding AddItem}">Add</Button>
    "AddItem" |> Binding.cmd AddItem
    "CanAddItem" |> Binding.oneWay (fun m -> m.Actions.Length > 5)