bjogui/Readme.org
Linus Björnstam 8941425f05 Tree views, a styles example, and the Readme for all of it
A tree node is a TreeViewItem: a header and items, patched by key as a
list's items are. The chosen node is chosen by its key, once the nodes are
there, and a click sends the key. SelectedItem is a direct property, which
a clear does not reset, so no choice is set as null.

The Readme now covers menus and keys, dialogs, styles and themes, and the
new controls. docs/styles.png is examples/styles.bjo.

🤖 Generated with [ECA](https://eca.dev) (anthropic/claude-opus-4-8)

Co-Authored-By: eca-agent <git@eca.dev>
2026-10-25 18:10:53 +01:00

306 lines
14 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]].
[[file:docs/todo.png]]
The todo list above is [[file:examples/todo.bjo][examples/todo.bjo]].
#+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 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~.
#+BEGIN_SRC scheme
(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)
#+END_SRC
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.
#+BEGIN_SRC scheme
(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)))
#+END_SRC
* 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.
#+BEGIN_SRC scheme
(border (paint (text "Saved") #:colour "#c0392b")
#:colour "#2980b9" #:radius 8.0 #:background "#ecf0f1" #:padding 8.0)
#+END_SRC
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.
* Styles and themes
[[file:docs/styles.png]]
~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.
#+BEGIN_SRC scheme
(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")))
#+END_SRC
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.
[[file:examples/styles.bjo][examples/styles.bjo]] is the picture above.
* Menus and keys
#+BEGIN_SRC scheme
(menu-bar
(submenu "File"
(menu-item "New" New #:shortcut "Ctrl+N")
(menu-separator)
(menu-item "Quit" Quit #:shortcut "Ctrl+Q")))
#+END_SRC
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.
#+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.
* 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 |
#+BEGIN_SRC scheme
(Delete (next m (confirm "Delete it?" Deleted?)))
((Deleted? #t) (next (remove m)))
((Deleted? #f) (next m))
#+END_SRC
~#: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 path~ makes ~run-app~ save a PNG of the window once it is
shown, at twice its size, and close it. It is the environment variable
~BJOGUI_SCREENSHOT~ unless the program says otherwise, so any program can
be pictured with no change: ~BJOGUI_SCREENSHOT=docs/todo.png bjo run
examples/todo.bjo~ made the picture above.
- ~(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-press!~ presses keys, such as ~"Ctrl+S"~, as the window does.
- ~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 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 examples/counter.bjo
bjo run examples/stopwatch.bjo
bjo run examples/todo.bjo
bjo run examples/styles.bjo
#+END_SRC
* 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.
- A moved control is taken out of the panel and put back, so it may lose the
keyboard focus.