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

134 lines
5.2 KiB
Org Mode

#+TITLE: bjogui
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))
(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"))
#+END_SRC
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.
#+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 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
#+BEGIN_SRC sh
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.
- More controls: lists, grids, images, scroll views, menus.