Closed matthewcrews closed 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]
@JaggerJo Is there a way to cleanly integrate that with an Elmish-style app?
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
()
)
])
]
]
I feel nerd sniped...
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()
)
])
]
]
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
)
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:
I pushed my version to my repo here
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
)
)
It works! Thank you!
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 ()
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?
@JordanMarr I figured it out thanks to your help! It's so simple and beautiful now 😂
For those who find this in the future, the full solution can be found here
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.