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:
Linus Björnstam 2026-10-25 17:02:02 +01:00
parent 2f4c99ba62
commit 4f48282314
6 changed files with 460 additions and 67 deletions

View file

@ -1,7 +1,7 @@
#+TITLE: bjogui
Desktop programs in Bjolang, written as a model, an ~update~ and a ~view~, and
shown with [[https://avaloniaui.net][Avalonia]].
Desktop programs in Bjolang, written as a model, a ~gui-update~ and a ~view~,
and shown with [[https://avaloniaui.net][Avalonia]].
#+BEGIN_SRC scheme
(import (bjogui view))
@ -9,9 +9,9 @@ shown with [[https://avaloniaui.net][Avalonia]].
(type (: Msg (Union Inc Dec)))
(: update (-> Msg int int))
(defun (update msg n)
(match msg (Inc (+ n 1)) (Dec (- n 1))))
(: gui-update (-> Msg int (Tuple int (List (Event Msg)))))
(defun (gui-update msg n)
(next (match msg (Inc (+ n 1)) (Dec (- n 1)))))
(: view (-> int (View Msg)))
(defun (view n)
@ -21,7 +21,7 @@ shown with [[https://avaloniaui.net][Avalonia]].
(button "+" Inc)))
(defun (main)
(run-app 0 update view #:title "Counter"))
(run-app 0 gui-update view #:title "Counter"))
#+END_SRC
A program never names an Avalonia type. A view is a Bjolang value, an event
@ -30,7 +30,7 @@ is a message, and the controls are made and changed by the package.
* How it works
An event finds its message in the view its control was last given.
~update~ makes the next model from the message, and ~view~ makes the next
~gui-update~ makes the next model from the message, and ~view~ makes 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.
@ -57,14 +57,59 @@ They take keywords where they apply: ~#:size~ (the font size of a text),
children of a column or a row), ~#:enabled~ and ~#:placeholder~. A size of
zero, the default, leaves the size to the control.
* Work: commands and subscriptions
~gui-update~ is a plain function, so it cannot wait, and the compiler says so
if it tries. Work is a value it returns, and the app runs it.
A *command* is an ~(Event %m)~: an event whose answer is a message. ~(next m
command ...)~ is the next model and the commands to start; ~(next m)~ is the
model alone. The app syncs each command in a fiber of its own and gives its
message to ~gui-update~. Any event will do: a ~timeout~, a ~blocking~ call, a
channel, a ~choose~ of several.
#+BEGIN_SRC scheme
(Load (next (record-set m (loading #t))
(wrap (blocking (fun () (file-read-text path))) Loaded)))
#+END_SRC
A *subscription* is a source of messages that runs while the model asks for
it. ~subscriptions~ gives the list for a model. After each update the app
starts those that are new and cancels those that are gone; the others keep
running. A subscription is known by its key.
- ~(every duration msg)~ sends ~msg~ each time ~duration~ has passed.
- ~(subscription key next-event)~ is a source of your own: ~next-event~ gives
the event of the next message, and is called again after each one.
~(subscription "jobs" (fun () (wrap (chan-recv jobs) Got)))~ sends what
comes on a channel.
#+BEGIN_SRC scheme
(: subscriptions (-> Model (List (Sub Msg))))
(defun (subscriptions m)
(if (.running m) (list (every (milliseconds 100) Tick)) '()))
#+END_SRC
The fibers belong to the app. They post their messages to the UI thread, so
~gui-update~ and ~view~ always run there. When the window is closed they are
cancelled, and the program ends.
* Running: (bjogui app)
- ~(run-app init update view #:title #:width #:height)~ opens a window and
runs until it is closed.
- ~(start-app init update view on-root)~ makes the controls with no window,
and gives ~on-root~ the control to show. ~app-dispatch!~ sends a message,
~app-model~ reads the model, and ~app-control-at~ finds a control by its
path of child indices. ~tests/app.bjo~ uses these to drive an app through
- ~(run-app init gui-update view #:subscriptions #:title #:width #:height)~
opens a window and runs until it is closed.
- ~(start-app init gui-update view on-root #:subscriptions)~ makes the controls
with no window, and gives ~on-root~ the control to show. Its fibers belong
to the scope it is started in.
- ~app-dispatch!~ gives a message on the UI thread, and ~app-send!~ from any
thread.
- ~app-run-pending!~ runs what fibers have posted, where no loop of
Avalonia's does.
- ~app-stop!~ cancels the subscriptions and drops later messages.
- ~app-model~ reads the model, and ~app-control-at~ finds a control by its
path of child indices.
~tests/app.bjo~ and ~tests/effects.bjo~ use these to drive an app through
its real controls.
* Requirements
@ -77,12 +122,13 @@ zero, the default, leaves the size to the control.
* Tests
#+BEGIN_SRC sh
bjo run tests/app.bjo # prints "bjogui ok"
bjo run tests/app.bjo # prints "bjogui ok"
bjo run tests/effects.bjo # prints "bjogui effects ok"
bjo run examples/counter.bjo
bjo run examples/stopwatch.bjo
#+END_SRC
* Not yet
- Keyed children, for lists whose items move.
- Messages from fibers and timers, for work that is not a click.
- More controls: lists, grids, images, scroll views, menus.