bjogui/Readme.org
Linus Björnstam 4f48282314 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>
2026-10-25 17:02:02 +01:00

5.2 KiB

bjogui

Desktop programs in Bjolang, written as a model, a gui-update and a view, and shown with Avalonia.

(import (bjogui view))
(import (bjogui app))

(type (: Msg (Union Inc Dec)))

(: 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)
  (row #:spacing 8.0 #:margin 16.0
    (button "-" Dec)
    (text #"${n}")
    (button "+" Inc)))

(defun (main)
  (run-app 0 gui-update view #:title "Counter"))

A program never names an Avalonia type. A view is a Bjolang value, an event 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. 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.

Children are matched by their place in the list. A child whose kind changes gets a new control.

A control that is changed raises events of its own: a text input whose text is set says that its text changed. Those events come while the controls are being changed, and they are dropped, so only the user's events are messages.

The views: (bjogui view)

Constructor Control Sends
(text s) TextBlock nothing
(button label msg) Button msg on a click
(text-input value on-input) TextBox (on-input text) on a change
(checkbox label checked? on-toggle) CheckBox (on-toggle checked)
(column child ...), (row child ...) StackPanel nothing

They take keywords where they apply: #:size (the font size of a text), #:width, #:height, #:margin, #:padding, #:spacing (between the 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.

(Load (next (record-set m (loading #t))
            (wrap (blocking (fun () (file-read-text path))) Loaded)))

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.
(: subscriptions (-> Model (List (Sub Msg))))
(defun (subscriptions m)
  (if (.running m) (list (every (milliseconds 100) Tick)) '()))

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 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

  • Avalonia 11.3, which bjo restores from NuGet. On Linux it needs X11 or XWayland.
  • A Bjolang with (std interop), which makes the delegates that Avalonia's events take.

Tests

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

Not yet

  • Keyed children, for lists whose items move.
  • More controls: lists, grids, images, scroll views, menus.