fsprojects / Avalonia.FuncUI

Develop cross-plattform GUI Applications using F# and Avalonia!
https://funcui.avaloniaui.net/
MIT License
981 stars 74 forks source link

Create a delayed event with a resetting timer #360

Closed matthewcrews closed 1 year ago

matthewcrews commented 1 year ago

I am using AvaloniaEdit in a project with an example here. I want the user to be able to input text into the editor, and when there is a 1-second pause, I want a compile step to run. What I can't figure out is how to register a delayed event with a timer that resets whenever the user inputs into the TextEditor.

Below is an example of what I would like to be able to do. I need an event handler that fires when the pause expires.

let view (state: State) dispatch =
    DockPanel.create [
        DockPanel.children [
            Menu.view ()
            TextEditor.create [
                TextEditor.showLineNumbers true
                TextEditor.onTextInput (fun e ->
                    // Reset Delay
                    ())
            ]
        ]
    ]
JaggerJo commented 1 year ago

Not sure how to do this with elmish. Maybe @JordanMarr knows.

If you use components it's simple. There is an example in the contact book app.

The search field has exactly this functionality.

I think the context extension is called 'useDeferred' (yes, it's a bad name)

[I'm OTG right now]

matthewcrews commented 1 year ago

@JaggerJo Is there a way to cleanly integrate that with an Elmish-style app?

matthewcrews commented 1 year ago

I feel like I'm getting close using the Component style. The ContactBook example is close to what I want. Ideally I want the user to be able to make changes to the AvaloniaEdit control. When they make edits it keeps resetting a timer for a 1 second delay. Once the delay completes, a lambda is called and a dispatch is run and the State is updated.

This code is close, I think, but isn't quite right. What I can't quite figure out is the resetting delay and passing in a lambda that I want called.

module AvaloniaEditor.View

open System.Runtime.CompilerServices
open System.Timers
open Avalonia.Controls
open Avalonia.FuncUI
open Avalonia.FuncUI.DSL
open AvaloniaEditor
open AvaloniaEdit

type IComponentContext with

    member inline ctx.useDeferred<'a>(outer: IWritable<'a>, delay: int) =
        let inner = ctx.useState (outer.Current)

        ctx.useEffect (
            handler = (fun _ ->
                inner.Set outer.Current
            ),
            triggers = [ EffectTrigger.AfterChange outer ]
        )

        let timer = ctx.useState (
            initialValue = (
                let timer = new Timer(Interval = delay, AutoReset = false)
                timer.Elapsed.Add (fun _ ->
                    printfn "Happened"
                    outer.Set inner.Current
                )
                timer
            ),
            renderOnChange = false
        )

        ctx.useEffect (
            handler = (fun _ ->
                timer.Current.Stop()
                timer.Current.Start()
            ),
            triggers = [ EffectTrigger.AfterChange inner ]
        )

        inner

let view (state: State) dispatch =
    let mutable editor = Unchecked.defaultof<_>
    DockPanel.create [
        DockPanel.children [
            Menu.view ()
            Component.create ("text editor", fun ctx ->
                let text = ctx.useState (None: string option)
                let textDeferred = ctx.useDeferred (text, 1000)

                TextEditor.create [
                    TextEditor.init (fun newEditor ->
                        editor <- newEditor)
                    TextEditor.showLineNumbers true
                    TextEditor.onTextInput (fun e ->
                        if Some editor.Text <> textDeferred.Current then
                            if System.String.IsNullOrEmpty editor.Text then
                                textDeferred.Set None
                            else
                                textDeferred.Set (Some editor.Text)
                        else
                            ()
                    )
                ])
        ]
    ]
JordanMarr commented 1 year ago

I feel nerd sniped...

matthewcrews commented 1 year ago

I also tried this approach. It's close, but types aren't lining up. I've pushed this version to my repo. I'm happy to pay someone to figure out how to get this to work.

The delay logic appears to work. The problem is that I need it to call some logic that hooks back into the Elmish loop. I also got a "wrong thread" error at one point. I feel like there should be some way to hook this up with subscriptions, but I'm not familiar enough with how they work.

The goal is that there is a timer running that gets reset every time a user enters a keystroke into the Editor. I don't want it to loop through constantly. I want the timer to restart when keystrokes start again. When the timer runs out, the contents of the Editor are sent to the dispatch loop to be evaluated and added to the state. In the actual use case, the text is parsed into other objects that are updated on the state. I'm not doing that here because I'm trying to keep the example simple.

let view (state: State) (dispatch: Msg -> State -> State) =
    let mutable editor = Unchecked.defaultof<TextEditor>

    DockPanel.create [
        DockPanel.children [
            Menu.view ()
            Component.create ("text editor", fun ctx ->
                let timer =
                    ctx.useState (
                        initialValue = (
                            let timer = new Timer(Interval = 1_000, AutoReset = false)
                            timer.Elapsed.Add (fun _ ->
                                let editorText = editor.Text
                                dispatch (Msg.NewText editorText) state
                            )
                            timer
                        ),
                        renderOnChange = false
                    )

                TextEditor.create [
                    TextEditor.init (fun newEditor ->
                        editor <- newEditor)
                    TextEditor.showLineNumbers true
                    TextEditor.onTextInput (fun _ ->
                        timer.Current.Interval <- 1_000.0
                        timer.Current.Start()
                    )
                ])
        ]
    ]
JordanMarr commented 1 year ago

This approach assumes you are still using Elmish and requires the "System.Reactive" NuGet package.

module Examples.ReactiveEditor

open Elmish
open System
open Avalonia.FuncUI.Elmish
open Avalonia.Layout
open Avalonia.Controls
open Avalonia.FuncUI
open Avalonia.FuncUI.DSL
open Avalonia.FuncUI.Types
open Avalonia.FuncUI.Elmish.ElmishHook
open System.Reactive.Subjects
open System.Reactive.Linq

type Model = 
    { 
        Text: string
        Counter: int
        EditorChangeStream: Subject<string>
    }

type Msg = 
    | EditorChanged of string
    | IncrementCounter

let init() = 
    { 
        Text = ""
        Counter = 0
        EditorChangeStream = new Subject<string>()
    }, Cmd.none

let update msg model = 
    match msg with
    | EditorChanged txt -> 
        { model with Text = txt }, Cmd.ofEffect (fun _ -> model.EditorChangeStream.OnNext(txt))
    | IncrementCounter -> 
        { model with Counter = model.Counter + 1 }, Cmd.none

let private subscriptions (model: Model) : Sub<Msg> =
    let editorChangeStream (dispatch: Msg -> unit) =
        model.EditorChangeStream
            .Throttle(TimeSpan.FromSeconds 1)
            .Subscribe(fun txt -> 
                dispatch IncrementCounter
            )

    [ 
        [ nameof editorChangeStream ], editorChangeStream
    ]

let view () = Component (fun ctx ->
    let model, dispatch = ctx.useElmish(init, update, Program.withSubscription subscriptions)

    StackPanel.create [
        StackPanel.children [

            TextBlock.create [
                TextBlock.text $"Triggered: {model.Counter}"
            ]

            TextBox.create [
                TextBox.text model.Text
                TextBox.onTextChanged (fun text -> dispatch (EditorChanged text))
            ]
        ]
    ]
    :> IView
)
matthewcrews commented 1 year ago

I think I'm making this too hard on myself. Would it be easier to setup an event that fires every n milliseconds? Sorry for taking this solution in different directions 😔.

I tried integrating your ideas, but when I run it I get a thread error saying it's an invalid thread: image

I pushed my version to my repo here

JordanMarr commented 1 year ago

The easiest way to handle that would be manually post it back to the main thread:

                model.EditorChangeStream
                    .Throttle(TimeSpan.FromSeconds 1)
                    .Subscribe(fun txt ->
                        Dispatcher.UIThread.Post(fun () ->
                            dispatch IncrementCounter
                        )
                    )
matthewcrews commented 1 year ago

It works! Thank you!

JordanMarr commented 1 year ago

Happy to help!

Rather than manually posting it back, you could also use |> Program.runWithAvaloniaSyncDispatch ().

type MainWindow() as this =
    inherit HostWindow()
    do
        base.Title <- "Avalonia Editor"
        base.Height <- 600.0
        base.Width <- 800.0

        let subscriptions (model: Model) : Sub<Msg> =
            let editorChangeStream (dispatch: Msg -> unit) =
                model.EditorChangeStream
                    .Throttle(TimeSpan.FromSeconds 1)
                    .Subscribe(fun txt ->
                        dispatch IncrementCounter
                    )

            [
                [ nameof editorChangeStream ], editorChangeStream
            ]

        Elmish.Program.mkProgram Model.init Model.update View.view
        |> Program.withHost this
        |> Program.withConsoleTrace
        |> Program.withSubscription subscriptions
        |> Program.runWithAvaloniaSyncDispatch ()
matthewcrews commented 1 year ago

Ah, that's nice to know!

Another question, is it really necessary to have the Subject on the Model type? That just seems odd to me, but maybe I'm missing something.

Also, is it possible to do this without using Cmd or is that necessary to hook all the events and callbacks together?

matthewcrews commented 1 year ago

@JordanMarr I figured it out thanks to your help! It's so simple and beautiful now 😂

matthewcrews commented 1 year ago

For those who find this in the future, the full solution can be found here