Commands and subscriptions, and gui-update
gui-update is a plain function, so it cannot wait. It returns the work to do as values. A command is an (Event %m), synced in a fiber of its own, whose answer is the next message. A subscription is a source of messages that the model asks for: after each update the app starts the new ones and cancels the ones that are gone, by key. every sends a message on a timer, and subscription takes a source of one's own. The fibers belong to the app's scope and post their messages to the UI thread, so gui-update and view only run there. Closing the window cancels them. The prelude's path macros already use the name update, hence gui-update. 🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-opus-4-8) Co-Authored-By: eca-agent <git@eca.dev>
This commit is contained in:
parent
2f4c99ba62
commit
4f48282314
6 changed files with 460 additions and 67 deletions
230
src/app.bjo
230
src/app.bjo
|
|
@ -4,24 +4,34 @@
|
|||
|
||||
;; bjogui/src/app.bjo — the module (bjogui app).
|
||||
;;
|
||||
;; Runs a program written as a model, an `update` and a `view`, in an Avalonia
|
||||
;; window. Every Avalonia name stays in this module: what it exports takes
|
||||
;; and gives Bjolang values, and a control only as an opaque `Control`.
|
||||
;; Runs a program written as a model, a `gui-update` and a `view`, in an
|
||||
;; Avalonia window. Every Avalonia name stays in this module: what it exports
|
||||
;; takes and gives Bjolang values, and a control only as an opaque `Control`.
|
||||
;;
|
||||
;; The loop: an event finds its message in the view the control was last
|
||||
;; given. `update` makes the next model from it, and `view` the next view.
|
||||
;; given. `gui-update` makes the next model from it, and `view` the next view.
|
||||
;; The next view is compared with the last one, control by control, and only
|
||||
;; what differs is changed. So a control keeps its focus, its caret and its
|
||||
;; scroll position when the model changes.
|
||||
;;
|
||||
;; `gui-update` does no work of its own: it is a plain function, so it cannot
|
||||
;; wait. Work is a value it returns. A command is an `(Event %m)`: the app
|
||||
;; syncs it in a fiber, and gives the message it answers to `gui-update`. A
|
||||
;; subscription is a source of messages that runs as long as the model asks
|
||||
;; for it: after each update the app starts the subscriptions that are new
|
||||
;; and stops those that are gone.
|
||||
;;
|
||||
;; Every control is made with plain constructors and changed with
|
||||
;; `AvaloniaObject.SetValue`, so no Avalonia class is subclassed.
|
||||
|
||||
(export
|
||||
App Live Control
|
||||
run-app
|
||||
App Live Control Sub
|
||||
run-app next
|
||||
;; Subscriptions.
|
||||
every subscription
|
||||
;; For tests and for hosts of their own: an app with no window.
|
||||
start-app app-dispatch! app-model app-control app-control-at)
|
||||
start-app app-dispatch! app-send! app-stop! app-run-pending!
|
||||
app-model app-control app-control-at)
|
||||
|
||||
(import (std prelude))
|
||||
(import (std interop))
|
||||
|
|
@ -67,7 +77,10 @@
|
|||
(AppBuilder (: Avalonia.AppBuilder))
|
||||
(ILifetime (: Avalonia.Controls.ApplicationLifetimes.IApplicationLifetime))
|
||||
(Lifetime (: Avalonia.Controls.ApplicationLifetimes.ClassicDesktopStyleApplicationLifetime
|
||||
(-> Lifetime))))
|
||||
(-> Lifetime)))
|
||||
(Dispatcher (: Avalonia.Threading.Dispatcher))
|
||||
(UiPriority (: Avalonia.Threading.DispatcherPriority))
|
||||
((DispatcherOp %a) (: Avalonia.Threading.DispatcherOperation)))
|
||||
|
||||
(import/extern
|
||||
;; Properties, as the objects `SetValue` takes.
|
||||
|
|
@ -108,6 +121,13 @@
|
|||
(panel-children (: Avalonia.Controls.Panel.Children (-> Panel Controls) #:get))
|
||||
(clr-string (: System.Convert.ToString (-> string ClrString)))
|
||||
|
||||
;; The UI thread. Controls may be touched only there, so a message from a
|
||||
;; fiber is posted to it.
|
||||
(ui-dispatcher (: Avalonia.Threading.Dispatcher.UIThread #:get))
|
||||
(ui-thread? (: Avalonia.Threading.Dispatcher.CheckAccess (-> Dispatcher bool)))
|
||||
(ui-post (: Avalonia.Threading.Dispatcher.InvokeAsync (-> Dispatcher (-> Unit) (DispatcherOp Unit))))
|
||||
(ui-run-jobs (: Avalonia.Threading.Dispatcher.RunJobs (-> Dispatcher (Nullable UiPriority) void)))
|
||||
|
||||
;; The window and the application.
|
||||
(configure (: Avalonia.AppBuilder.Configure (-> (-> Application) AppBuilder)))
|
||||
(use-platform-detect (: Avalonia.AppBuilderDesktopExtensions.UsePlatformDetect
|
||||
|
|
@ -226,17 +246,66 @@
|
|||
(: view (View %m) #:mutable)
|
||||
(: children (MutableVec (Live %m))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Commands and subscriptions
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; A source of messages that runs while the model asks for it. `key` says
|
||||
;; which one it is: after an update, a subscription whose key was there before
|
||||
;; keeps running, and is not started again.
|
||||
;;
|
||||
;; `next-event` gives the event of the next message. A fiber of the
|
||||
;; subscription's own calls it, syncs the event, sends the message, and calls
|
||||
;; it again. The fiber is cancelled at that `sync` when the key is gone, and
|
||||
;; when the app stops.
|
||||
(type (: (Sub %m) #:opaque (Record (: key string)
|
||||
(: next-event (-> (Event %m))))))
|
||||
|
||||
;; A subscription to a source of your own: `(subscription "jobs" (fun ()
|
||||
;; (chan-recv jobs)))` sends each value that comes on a channel. Two
|
||||
;; subscriptions with the same key are one: the first is the one that runs.
|
||||
(: subscription (-> string (-> (Event %m)) (Sub %m)))
|
||||
(defun (subscription key next-event)
|
||||
(Sub (key key) (next-event next-event)))
|
||||
|
||||
;; Sends `msg` each time `d` has passed. The time is counted from the last
|
||||
;; message, so a slow update makes the next one later and they do not queue
|
||||
;; up.
|
||||
(: every (-> Duration %m (Sub %m)) (where (Written %m)))
|
||||
(defun (every d msg)
|
||||
(Sub (key #"every ${(duration-ticks d)} ${(written msg)}")
|
||||
(next-event (fun () (wrap (timeout d) (fun (_) msg))))))
|
||||
|
||||
;; The next model, and the commands to run. `(next m)` is the model alone.
|
||||
(: next (-> %model #:rest (Event %m) (Tuple %model (List (Event %m)))))
|
||||
(defun (next m #:rest commands)
|
||||
(Tuple m (->list commands)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; The app
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; A running program.
|
||||
;;
|
||||
;; `patching` is true while the controls are being changed. A control that
|
||||
;; is changed raises its own events, such as a text input whose text is set,
|
||||
;; and those are not the user's: they are dropped.
|
||||
(type (: (App %model %m) #:opaque (Record (: model %model #:mutable)
|
||||
(: update (-> %m %model %model))
|
||||
(: view (-> %model (View %m)))
|
||||
(: root (Option (Live %m)) #:mutable)
|
||||
(: patching bool #:mutable)
|
||||
(: on-root (-> Control void)))))
|
||||
;;
|
||||
;; `home` is the scope the app's fibers belong to. A message can arrive from
|
||||
;; Avalonia on a thread with no scope, so each update is run in it.
|
||||
;; `subs` holds each running subscription's key, and the scope it runs in, so
|
||||
;; that one can be cancelled and the others not.
|
||||
(type (: (App %model %m) #:opaque
|
||||
(Record (: model %model #:mutable)
|
||||
(: gui-update (-> %m %model (Tuple %model (List (Event %m)))))
|
||||
(: view (-> %model (View %m)))
|
||||
(: subscriptions (-> %model (List (Sub %m))))
|
||||
(: root (Option (Live %m)) #:mutable)
|
||||
(: patching bool #:mutable)
|
||||
(: stopped bool #:mutable)
|
||||
(: home Scope)
|
||||
(: subs (List (Tuple string Scope)) #:mutable)
|
||||
(: on-root (-> Control void)))))
|
||||
|
||||
(: (-> (App %model %m) %model))
|
||||
(defun (app-model app) (.model app))
|
||||
|
|
@ -261,29 +330,110 @@
|
|||
((Some live) (walk live path))
|
||||
(None (raise (cast Exception (InvalidOperation. "the app has no view"))))))
|
||||
|
||||
;; Makes the next model from `msg`, and shows its view.
|
||||
;; Makes the next model from `msg`, shows its view, starts its commands, and
|
||||
;; starts and stops subscriptions. Only on the UI thread; from anywhere else,
|
||||
;; use `app-send!`. A stopped app drops the message.
|
||||
(: (-> (App %model %m) %m void))
|
||||
(defun (app-dispatch! app msg)
|
||||
(record-set! app (model ((.update app) msg (.model app))))
|
||||
(render! app))
|
||||
(unless (.stopped app)
|
||||
(def (Tuple model commands) ((.gui-update app) msg (.model app)))
|
||||
(record-set! app (model model))
|
||||
(step! app commands)))
|
||||
|
||||
;; Gives `msg` to the app from any thread. Off the UI thread it is posted to
|
||||
;; the UI thread, and runs there after the messages posted before it.
|
||||
(: (-> (App %model %m) %m void))
|
||||
(defun (app-send! app msg)
|
||||
(if (ui-thread? ui-dispatcher)
|
||||
(app-dispatch! app msg)
|
||||
(post! app msg)))
|
||||
|
||||
;; Posts `msg` to the UI thread. What a fiber does: it is never on the UI
|
||||
;; thread, and it must not run an update itself.
|
||||
(: (-> (App %model %m) %m void))
|
||||
(defun (post! app msg)
|
||||
(ignore (ui-post ui-dispatcher (fun () (app-dispatch! app msg) unit))))
|
||||
|
||||
;; Runs the messages that fibers have posted to the UI thread, now. For a test
|
||||
;; or a host with no window, where no loop of Avalonia's runs them.
|
||||
(: (-> (App %model %m) void))
|
||||
(defun (app-run-pending! app)
|
||||
(ui-run-jobs ui-dispatcher (clr-default)))
|
||||
|
||||
;; Stops the app: its subscriptions are cancelled, and later messages are
|
||||
;; dropped. A command that is still running is cancelled when the scope the
|
||||
;; app was started in ends.
|
||||
(: (-> (App %model %m) void))
|
||||
(defun (app-stop! app)
|
||||
(record-set! app (stopped #t))
|
||||
(loop (:for (Tuple _ scope) (.subs app)) (:do (stop-scope! scope)))
|
||||
(record-set! app (subs '())))
|
||||
|
||||
(: (-> (App %model %m) (List (Event %m)) void))
|
||||
(defun (step! app commands)
|
||||
(render! app)
|
||||
(in-scope (.home app)
|
||||
(loop (:for command commands)
|
||||
(:do (spawn/daemon (run-command app command)))))
|
||||
(sync-subs! app))
|
||||
|
||||
(: (-bjo-> (App %model %m) (Event %m) void))
|
||||
(defbjo (run-command app command)
|
||||
(post! app (sync command)))
|
||||
|
||||
(: (-bjo-> (App %model %m) (-> (Event %m)) void))
|
||||
(defbjo (run-sub app next-event)
|
||||
(let go ()
|
||||
(post! app (sync (next-event)))
|
||||
(go)))
|
||||
|
||||
;; Cancels a subscription's scope, and so its fiber. The scope is not closed:
|
||||
;; closing waits, and nothing is owned in it to release.
|
||||
(: (-> Scope void))
|
||||
(defun (stop-scope! scope)
|
||||
(scope-cancel! scope (Requested "the subscription is gone")))
|
||||
|
||||
;; Starts a subscription in a scope of its own, inside the app's, so that it
|
||||
;; can be cancelled alone.
|
||||
(: (-> (App %model %m) (Sub %m) Scope))
|
||||
(defun (start-sub! app sub)
|
||||
(in-scope (.home app)
|
||||
(def scope (scope-open! 0))
|
||||
(in-scope scope
|
||||
(spawn/daemon (run-sub app (.next-event sub))))
|
||||
scope))
|
||||
|
||||
(: (-> (App %model %m) void))
|
||||
(defun (sync-subs! app)
|
||||
(def wanted ((.subscriptions app) (.model app)))
|
||||
(defun (wanted? key) (any? (fun (sub) (= (.key sub) key)) wanted))
|
||||
(def/mutable kept '())
|
||||
(loop (:for (Tuple key scope) (.subs app))
|
||||
(:do (if (wanted? key)
|
||||
(set! kept (cons (Tuple key scope) kept))
|
||||
(stop-scope! scope))))
|
||||
(loop (:for sub wanted)
|
||||
(:when (not (any? (fun (t) (= (fst t) (.key sub))) kept)))
|
||||
(:do (set! kept (cons (Tuple (.key sub) (start-sub! app sub)) kept))))
|
||||
(record-set! app (subs kept)))
|
||||
|
||||
(: (-> (App %model %m) void))
|
||||
(defun (render! app)
|
||||
(def next ((.view app) (.model app)))
|
||||
(def next-view ((.view app) (.model app)))
|
||||
(record-set! app (patching #t))
|
||||
(match (.root app)
|
||||
(None
|
||||
(def live (mount app next))
|
||||
(def live (mount app next-view))
|
||||
(record-set! app (root (Some live)))
|
||||
((.on-root app) (.control live)))
|
||||
((Some live)
|
||||
(def now (patch! app live next))
|
||||
(def now (patch! app live next-view))
|
||||
(unless (eq? now live)
|
||||
(record-set! app (root (Some now)))
|
||||
((.on-root app) (.control now)))))
|
||||
(record-set! app (patching #f)))
|
||||
|
||||
;; Gives the message that `pick` finds in the control's events to `update`.
|
||||
;; Gives the message that `pick` finds in the control's events to `gui-update`.
|
||||
(: (-> (App %model %m) (Live %m) (-> (On %m) (Option %m)) void))
|
||||
(defun (fire! app live pick)
|
||||
(unless (.patching app)
|
||||
|
|
@ -391,21 +541,27 @@
|
|||
(mutablevec-add! kids c)
|
||||
(.Add controls (.control c)))))
|
||||
|
||||
;; An app with no window: its view is made now, and `on-root` is given the
|
||||
;; control to show each time that control changes.
|
||||
(: (-> %model (-> %m %model %model) (-> %model (View %m)) (-> Control void)
|
||||
;; An app with no window: its view is made now, its subscriptions started,
|
||||
;; and `on-root` is given the control to show each time that control changes.
|
||||
;; Its fibers belong to the scope it is started in.
|
||||
(: (-> %model (-> %m %model (Tuple %model (List (Event %m)))) (-> %model (View %m))
|
||||
(-> Control void) (#:subscriptions (-> %model (List (Sub %m))))
|
||||
(App %model %m)))
|
||||
(defun (start-app init update view on-root)
|
||||
(def app (App (model init) (update update) (view view) (root None) (patching #f)
|
||||
(on-root on-root)))
|
||||
(render! app)
|
||||
(defun (start-app init gui-update view on-root #:subscriptions (fun (m) '()))
|
||||
(def app (App (model init) (gui-update gui-update) (view view)
|
||||
(subscriptions subscriptions) (root None) (patching #f) (stopped #f)
|
||||
(home (current-scope)) (subs '()) (on-root on-root)))
|
||||
(step! app '())
|
||||
app)
|
||||
|
||||
;; Runs the program in a window, until the window is closed.
|
||||
(: (-> %model (-> %m %model %model) (-> %model (View %m))
|
||||
;; Runs the program in a window, until the window is closed. Then the
|
||||
;; subscriptions and the commands still running are cancelled.
|
||||
(: (-> %model (-> %m %model (Tuple %model (List (Event %m)))) (-> %model (View %m))
|
||||
(#:subscriptions (-> %model (List (Sub %m))))
|
||||
(#:title string) (#:width double) (#:height double)
|
||||
void))
|
||||
(defun (run-app init update view #:title "bjogui" #:width 400.0 #:height 300.0)
|
||||
(defun (run-app init gui-update view #:subscriptions (fun (m) '())
|
||||
#:title "bjogui" #:width 400.0 #:height 300.0)
|
||||
;; The Application is made inside the setup, because making one uses the
|
||||
;; dispatcher, and the dispatcher is the platform's only after the setup.
|
||||
(def lifetime (Lifetime.))
|
||||
|
|
@ -418,7 +574,11 @@
|
|||
(cast Object width) Priority.LocalValue))
|
||||
(ignore (set-value (cast AvaloniaObject w) (cast AvaloniaProperty height-property)
|
||||
(cast Object height) Priority.LocalValue))
|
||||
(ignore (start-app init update view
|
||||
(fun (c) (window-content! (cast ContentControl w) (cast Object c)))))
|
||||
(main-window! lifetime w)
|
||||
(ignore (start-lifetime lifetime (: (make-array 0) (Array ClrString)))))
|
||||
(with-cancel (cancel)
|
||||
(def app (start-app init gui-update view
|
||||
(fun (c) (window-content! (cast ContentControl w) (cast Object c)))
|
||||
#:subscriptions subscriptions))
|
||||
(main-window! lifetime w)
|
||||
(ignore (start-lifetime lifetime (: (make-array 0) (Array ClrString))))
|
||||
(app-stop! app)
|
||||
(cancel (Requested "the window is closed"))))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue