A component draws itself with (bjogui draw), a pen over Avalonia's DrawingContext, and hears the pointer, the wheel, the keys and the focus. It keeps a state of its own between inputs, and sends outputs that drawn makes the app's messages. It is drawn again only when its value, its state or its size changes, and no Avalonia class is subclassed: the picture is a DrawingImage on a canvas. start-headless! sets Avalonia up with no screen, drawing with Skia, so tests/components.bjo draws components and reads their pixels. The mixer example has knobs and a timeline. 🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-opus-4-8) Co-Authored-By: eca-agent <git@eca.dev> |
||
|---|---|---|
| docs | ||
| examples | ||
| src | ||
| tests | ||
| .gitignore | ||
| LICENSE | ||
| manifest.bjodat | ||
| packages.lock.json | ||
| Readme.org | ||
bjogui
- How it works
- The views: (bjogui view)
- Colours and borders
- Components: view-map
- Drawing: canvas
- Controls of your own: component and (bjogui draw)
- Styles and themes
- Menus and keys
- Work: commands and subscriptions
- Dialogs: (bjogui dialogs)
- Running: (bjogui app)
- Requirements
- Tests
- Not yet
Desktop programs in Bjolang, written as a model, a gui-update and a view,
and shown with Avalonia.

The todo list above is examples/todo.bjo.
(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 key, and those with no key by their place among
the others with none. Give each item of a list that can change order a key
with (keyed id view): a child that moves then keeps its control, and the
control is moved rather than made again. 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 |
(text-area value on-input) |
TextBox, several lines |
(on-input text) |
(checkbox label checked? on-toggle) |
CheckBox |
(on-toggle checked) |
(slider value minimum maximum on-change) |
Slider |
(on-change value) |
(progress-bar value) |
ProgressBar |
nothing |
(dropdown options selected on-select) |
ComboBox |
(on-select index) |
(image path) |
Image |
nothing |
(list-box views selected on-select) |
ListBox |
(on-select index) |
(tabs selected on-select (tab header view) ...) |
TabControl |
(on-select index) |
(column child ...), (row child ...) |
StackPanel |
nothing |
(grid #:columns #:rows child ...) |
Grid |
nothing |
(scroll child) |
ScrollViewer |
nothing |
(border child) |
Border |
nothing |
(toggle-switch label on? on-toggle) |
ToggleSwitch |
(on-toggle on) |
(radio label chosen? msg) |
RadioButton |
msg when it is chosen |
(number-input value on-change) |
NumericUpDown |
(on-change value) |
(date-picker "2026-10-25" on-change) |
DatePicker |
(on-change date) |
(auto-complete value options on-input) |
AutoCompleteBox |
(on-input text) |
(expander header expanded? on-toggle child) |
Expander |
(on-toggle expanded) |
(tree-view chosen on-pick (tree-node key header ...) ...) |
TreeView |
(on-pick key) |
(flow child ...) |
WrapPanel |
nothing |
(dock child ...), with at-top, at-left … |
DockPanel |
nothing |
(menu-bar (submenu label (menu-item label msg) ...)) |
Menu |
msg when an item is chosen |
They take keywords where they apply: #:size (the font size of a text),
#:wrap (a text breaks its lines at its width), #:width, #:height,
#:margin, #:padding, #:spacing (between the children of a column, a row
or a grid), #:enabled, #:placeholder, #:step (a slider's arrow key) and
#:maximum (a progress bar's full, 1 unless told otherwise). A size of zero,
the default, leaves the size to the control.
A grid's #:columns and #:rows are sizes as Avalonia writes them: "Auto"
fits the content, "*" shares what is left, "2*" takes two shares, and a
number is pixels. (at row column view) puts a view in a cell, counting from
0, with #:row-span and #:column-span.
(grid #:columns "Auto,*" #:rows "Auto,Auto" #:spacing 6.0
(at 0 0 (text "Volume")) (at 0 1 (slider (.volume m) 0.0 10.0 Volume))
(at 1 0 (text "Colour")) (at 1 1 (dropdown ["red" "green" "blue"] (.colour m) Colour)))
(scroll (apply column (vec->array (vec-map (fun (t) (keyed (.id t) (text (.title t))))
(.todos m))))
#:height 200.0)
An image that cannot be read shows nothing.
A list box's items are views, so an item can be anything: a text, a row with a checkbox, a dropdown. Give them keys if the list can change order. Each item is a control, so a list of thousands makes thousands of controls.
(tabs (.tab m) Tab
(tab "Todo" (list-box (vec-map (fun (t) (keyed (.id t) (text (.title t)))) (.todos m))
(.chosen m) Chose))
(tab "Settings" (checkbox "Dark" (.dark m) Dark)))
Colours and borders
(paint view #:colour #:background) colours any view: #:colour is the
text, #:background what is behind it. A colour is a name such as "red",
or "#rgb", "#rrggbb" or "#aarrggbb". The empty string, the default,
leaves the colour to the theme, and so does a colour that cannot be read.
(border child #:colour #:thickness #:radius #:background #:padding) draws
a line around a view, with round corners if #:radius is more than zero.
(border (paint (text "Saved") #:colour "#c0392b")
#:colour "#2980b9" #:radius 8.0 #:background "#ecf0f1" #:padding 8.0)
The Fluent theme gives a button its own colours when the pointer is over it or it is pressed, so a painted button shows the theme's colour then.
More wrappers: (tooltip view "text"), (with-context-menu view item ...)
for a menu on the right button, and (classed view "name" ...) for styles.
A tree node is known by its key, which must be its own in the tree. A node
is open at first when it says #:expanded; after that the user opens and
closes it.
Components: view-map
A part of a program can have a message type, an update and a view of its
own, and know nothing of the program it is in. (view-map f view) puts its
view in a larger one: each message it sends is given to f first, which is
usually the case of the larger message that carries it.
(type (: Msg (Union (: Colour PickerMsg) (: Width PickerMsg) ...)))
(column (view-map Colour (picker-view (.colour m)))
(view-map Width (picker-view (.width m))))
((Colour pm) (next (record-set m (colour (picker-update pm (.colour m))))))
Drawing: canvas

(canvas width height shape ...) is a surface to draw on. A shape is placed
by its own numbers, and any other view by (at-point x y view).
| Shape | Takes |
|---|---|
(rect x y width height) |
#:fill, #:stroke, #:stroke-width, #:radius |
(ellipse x y width height) |
#:fill, #:stroke, #:stroke-width |
(line x1 y1 x2 y2) |
#:stroke, #:stroke-width |
(path "M 10 10 L 50 10 L 30 40 Z") |
#:fill, #:stroke, #:stroke-width |
A path is written as SVG writes one. (on-press canvas f), (on-move canvas
f) and (on-release canvas f) send (f x y), the place on the canvas, when
a button is pressed, when the pointer moves, and when the button is let go.
A press takes the pointer, so a drag that leaves the canvas still reaches it.
Shapes are views like any other, so keyed ones are kept as their list
changes. examples/sketch.bjo is the picture
above, and uses two components of its own.
Controls of your own: component and (bjogui draw)

(component state draw #:input #:cursor #:focusable) makes a control that
draws itself and hears the pointer and the keys. It keeps a state of its own,
such as where a drag started or where the pointer hovers, which the model
never sees. (drawn c value on-output) puts it in a view: it shows value,
and each output it sends is made a message with on-output, as a slider's
value is.
(import (bjogui draw))
;; Where a drag started, and the value then.
(type (: Drag (Union Idle (: Dragging double double))))
(: knob (Component double Drag double))
(def knob
(component Idle
(fun (pen w h s v)
(fill-circle pen (/ w 2.0) (/ h 2.0) (/ w 2.0) "#2c3e50")
(stroke-arc pen (/ w 2.0) (/ h 2.0) (- (/ w 2.0) 6.0) 2.4 (* v 4.7) "orange"
#:width 5.0 #:cap "round"))
#:input (fun (in w h s v)
(match (Tuple in s)
((Tuple (PointerDown p) _) (Tuple (Dragging (.y p) v) None))
((Tuple (PointerMove p) (Dragging y0 v0))
(Tuple s (Some (max 0.0 (min 1.0 (+ v0 (/ (- y0 (.y p)) 150.0)))))))
((Tuple (PointerUp _) _) (Tuple Idle None))
(_ (Tuple s None))))
#:cursor (fun (s v) "Hand")))
;; In a view:
(drawn knob (.volume model) Volume #:width 80.0 #:height 80.0)
(draw pen width height state value)draws the component at its size. It is called again when the value, the state or the size changes, and not otherwise.(input in width height state value)gives the next state, and maybe an output.inis anInput:(PointerDown p),(PointerMove p),(PointerUp p),PointerEnter,PointerLeave,(Wheel p dx dy),(KeyDown "Ctrl+Z"),FocusInandFocusOut. APointerhasxandyfrom the component's corner,button("left","right","middle"),clicks(2 for a double click) andmods("Ctrl+Shift").- A press takes the pointer, so a drag that leaves the component still
reaches it. A
#:focusablecomponent takes the keys when it is pressed. - An input that changes the state or sends an output is the component's, and goes no further. Any other goes on to the parents, so a key the component does not use still works as a shortcut.
- With no
#:widthor#:height, a component fills the space it is given, and is drawn again when that changes. - Make a component once, at the top of a module. A view in the place of another component is a new control, whose state starts again.
- A component that sends nothing needs its type written, because its output
type is not known:
(: lamp (Component bool Unit Unit)).
(bjogui draw) is the pen. Its coordinates start at the component's top left
corner, its colours are strings, and its angles are radians, clockwise from
the right, as sin and cos take them.
- Shapes:
fill-rect,stroke-rect(#:radius),fill-circle,stroke-circle,fill-ellipse,stroke-ellipse,stroke-line,stroke-polyline,fill-polygon,stroke-arc, andfill-pathandstroke-pathwith SVG path data. Lines take#:width,#:capand#:dash. - Text and pictures:
draw-text(#:size,#:bold,#:font,#:align,#:max-width),text-sizeto lay text out, anddraw-image. translated,scaled,rotated,clippedandfadedchange what a function given to them draws.
The drawing is vectors, so it is sharp at any scale.
examples/mixer.bjo has knobs and a timeline with
a playhead, marks and a hover line. start-headless! sets Avalonia up with no
screen, so that a test can draw components and read their pixels; see
tests/components.bjo.
Styles and themes

run-app's #:theme gives "light", "dark", or anything else for the
system's, for a model, so a setting can switch it. #:styles is a list of
rules made by style: a kind, by the name of its constructor, a class
given with classed, and the look.
(run-app init gui-update view
#:theme (fun (m) (if (.dark m) "dark" "light"))
#:styles (list (style "text" #:class "title" #:font-size 22.0 #:colour "#f1c40f")
(style "button" #:class "primary" #:background "#27ae60" #:colour "white"
#:hover-background "#2ecc71" #:pressed-background "#1e8449")))
A rule takes #:colour, #:background, #:font-size, #:padding and
#:radius. A button's rule also takes #:hover-colour, #:hover-background,
#:pressed-colour and #:pressed-background, which are set inside its
template, as the Fluent theme sets its own; no other kind has them.
examples/styles.bjo is the picture above.
Menus and keys
(menu-bar
(submenu "File"
(menu-item "New" New #:shortcut "Ctrl+N")
(menu-separator)
(menu-item "Quit" Quit #:shortcut "Ctrl+Q")))
A menu bar is a view, so it goes where the program puts it, usually at the
top of a dock. A menu item's #:shortcut is shown beside it, and the keys
send its message while it is in the view and enabled. run-app's #:keys
gives more, for a model: (fun (m) (list (key "F5" Refresh))). Keys are
the window's after the control that has the keyboard had its chance, so a
text input keeps Ctrl+C.
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)sendsmsgeach timedurationhas passed.(subscription key next-event)is a source of your own:next-eventgives 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.
Dialogs: (bjogui dialogs)
A dialog is a command, and its answer comes back as a message.
| Command | Sends |
|---|---|
(alert text msg) |
msg when it is closed |
(confirm text on-answer) |
(on-answer #t) for OK, #f otherwise |
(prompt text on-answer #:value) |
(on-answer (Some text)), or None |
(open-file on-answer #:filters) |
(on-answer (Some path)), or None |
(open-files on-answer) |
(on-answer paths) |
(save-file on-answer #:name) |
(on-answer (Some path)), or None |
(open-folder on-answer) |
(on-answer (Some path)), or None |
(quit) |
nothing: it closes the windows |
(Delete (next m (confirm "Delete it?" Deleted?)))
((Deleted? #t) (next (remove m)))
((Deleted? #f) (next m))
#:filters are kinds of file, each a name and patterns: (list (Tuple "Text"
'("*.txt" "*.md"))). run-app's #:on-close msg makes the close button
send msg instead of closing, and the program closes with (quit) when it
is ready, say after asking about unsaved work.
A dialog belongs to the main window. With none, as under start-app, each
answers as if it was cancelled. Avalonia's dialogs cannot be cancelled, so a
command that is cancelled stops waiting, and the dialog stays until it is
answered.
Running: (bjogui app)
(run-app init gui-update view #:subscriptions #:keys #:theme #:styles #:on-close #:title #:width #:height)opens a window and runs until it is closed.#:screenshot pathmakesrun-appsave a PNG of the window once it is shown, at twice its size, and close it. It is the environment variableBJOGUI_SCREENSHOTunless the program says otherwise, so any program can be pictured with no change:BJOGUI_SCREENSHOT=docs/todo.png bjo run examples/todo.bjomade the picture above.-
(start-app init gui-update view on-root #:subscriptions)makes the controls with no window, and giveson-rootthe control to show. Its fibers belong to the scope it is started in.app-dispatch!gives a message on the UI thread, andapp-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-press!presses keys, such as"Ctrl+S", as the window does.app-input!gives anInputto the component at a path, as its control does.app-modelreads the model, andapp-control-atfinds a control by its path of child indices.
tests/app.bjoandtests/effects.bjouse these to drive an app through its real controls.
Requirements
- Avalonia 11.3, which
bjorestores 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 tests/controls.bjo # prints "bjogui controls ok"
bjo run tests/lists.bjo # prints "bjogui lists ok"
bjo run tests/inputs.bjo # prints "bjogui inputs ok"
bjo run tests/menus.bjo # prints "bjogui menus ok"
bjo run tests/dialogs.bjo # prints "bjogui dialogs ok"
bjo run tests/tree.bjo # prints "bjogui tree ok"
bjo run tests/canvas.bjo # prints "bjogui canvas ok"
bjo run tests/components.bjo # prints "bjogui components ok"
bjo run examples/counter.bjo
bjo run examples/stopwatch.bjo
bjo run examples/todo.bjo
bjo run examples/styles.bjo
bjo run examples/sketch.bjo
bjo run examples/mixer.bjo
Not yet
- A data grid, and lists that make controls only for the items on screen.
- Dialogs of one's own, with a view and a state.
- The macOS menu bar.
- Pointer-over and pressed colours for kinds other than buttons.
- Controls of Avalonia's or another package's that bjogui does not wrap.
- A component asks for no size of its own: it is given one, or fills the space it gets. Measuring itself, an accessibility tree, and text input inside a component would need a subclass.
- A moved control is taken out of the panel and put back, so it may lose the keyboard focus.