The product's user interface
A product often has a screen: a dashboard in a car, a touch panel on a machine, a
small display on a sensor. In arche-lang you describe that screen in the model,
next to what it shows: the component that owns the display gets a ui { }
block, and each gauge, label and button in it is bound to the model — a variable,
a message the part receives, the state of a part beside it. When the model
changes, archec tells you what the screen no longer matches.
This page is a guide. Every word and property is in the
language reference; archec explain ui lists the checks.
A first screen#
component Dashboard "Driver display" {
in bus: data receives STATUS, BRAKE_FAULT
out cmd: data sends ENGAGE_REQ, STOP
var lights: bool = false
ui {
display 800x480
input touch
theme dark
screen Driving "Driving" {
row {
gauge speedo "Speed" { value bus.STATUS.speed }
label mode { text "Mode: ${ctrl.state}" }
}
row {
button engage "Engage" { enabled ctrl.state == Ready; on press send cmd ENGAGE_REQ(true) }
button stop "Stop" { style danger; on press send cmd STOP(true) }
button settings "Settings" { on press goto Settings }
}
}
screen Settings "Settings" {
toggle lights "Lights" { value lights }
button done "Done" { on press back }
}
overlay Warnings {
telltale brake "Brake fault" { on bus.BRAKE_FAULT; color amber }
}
}
}
Read it from the top:
- The device —
display 800x480(pixels),input touch(orencoder,keys,pointer),theme dark(orlight).dpi 160lets archec check that buttons are big enough for a finger. With nodisplay, the display of a board the part is built on is taken (itshardware { display … }). - Screens — what the display shows at one time. The UI starts on the first
one (or
initial Settings);goto Settingsandbackmove between them. Adialogis shown over the current screen until it is left; anoverlayis shown over every screen — the place for warnings. - Layout —
row(side by side),column(one under the other),grid(columns 3),stack(on top of each other),spacer(empty space). A screen's own children form a column.gap,padding,align,grow,widthandheightadjust them. - Elements —
label,gauge,bar,chart,telltale,image,list,table,button,toggle,slider,select,input,stepper, andcustomfor one you draw yourself. The name after the word (speedo) is optional; the "Text" after it is what the element says.
These words are words only inside ui { }. Anywhere else, label, button
or row are ordinary names: a part called label still works.
What an element shows: bindings#
value, enabled, visible and a telltale's on take an expression of the
action language — the same one ```arche bodies use. The
names in it are what the display's part knows:
| You write | It is |
|---|---|
lights |
a variable of the part |
state |
the part's own state |
bus.STATUS.speed |
the last STATUS received on port bus, its field speed |
bus.BRAKE_FAULT |
the last value of a message that is one value (message BRAKE_FAULT : bool) |
ctrl.state, ctrl.speed |
the state, or a variable, of a part beside it in the system (the preview and the simulator read the model; a real product gets them as messages) |
MAX_SPEED |
a constant |
DriveMode.MANUAL |
an enum value |
A state is compared by its name: ctrl.state == Ready. A text says values with
${…}: text "Obstacle ${bus.STATUS.distance} m".
archec knows each value's type, unit and range from the model. A gauge
on bus.STATUS.speed (a float32 unit km/h range 0..200) takes its unit and its
range from it; one that says unit mph is reported (ui-binding), as a toggle
bound to a number or a name the part does not have.
A toggle, slider, select, input or stepper bound to a variable of its
own part sets it when it changes. To do something else, write on change …
(value is the new value there).
What a press does: actions#
on press (also on release, on long-press, on change; a screen's on enter
and on exit) takes action-language statements — on its line, or in a block:
button stop "Stop" {
style danger
on press {
send cmd STOP(true)
goto Driving
}
}
| Statement | Does |
|---|---|
send cmd STOP(true) |
sends a message on an out port; arguments in field order, or STOP { hard: true } |
goto Settings |
shows another screen (or a dialog) |
back |
goes back to the screen before |
lights = not lights |
sets a variable of the part |
if … { … } else { … } |
decides |
reset() |
calls a function of the part |
archec checks that the port sends that message, that the arguments fit its fields,
that the screen exists — and that something receives it: a press on a port
with no link, or to a part that does not receive the message, is reported
(ui-dead-press).
The checks#
| Code | Finds |
|---|---|
ui-structure |
a word that is not one of a screen (guage), two elements of one name, no screen, a display that is not WxH |
ui-binding |
a name the UI cannot see, the wrong type for the element, a unit other than the element's, a send its port does not make, a goto to no screen |
ui-dead-press |
a press nothing receives; a button with no action (a hint) |
ui-unreachable |
a screen no goto reaches; a screen with no way out (the quick fix adds a Back button) |
ui-safety |
a message a hazard or a safety goal names, shown on some screens only and not by a telltale in an overlay |
ui-fit |
a screen whose content needs more than the display, a text wider than it, a touch target under 9 mm |
ui-a11y |
contrast under WCAG AA, a button with no text, a telltale that says it by its color alone |
ui-text |
a text not translated when the project's other texts are |
ui-test |
a test step that presses or reads what no ui has (press hmi.engag, expect hmi.mode.colour), a change of a button (Testing a UI) |
archec check reports them with the others; the editor shows them as you type,
completes the element words inside a screen, the names a binding can read
(bus. → its messages, bus.STATUS. → their fields), the screens after goto,
and hovering an element says what it is bound to.
In the diagram: the palette#
The diagram's palette has a User interface section: Screen, Dialog, Overlay,
the layouts (row, column, grid, stack, spacer) and every element. Drop
one on a block, and it is written into the ui { } of the block's definition
(for a part, the component it is a copy of), as one undoable edit:
- a Screen (a dialog, an overlay) asks its name and goes at the end of the ui —
and the block gets a
ui { }when it has none; - an element or a layout goes into the screen (asked when the ui has several),
at its end, with a first binding of what the ui can read: a gauge the first
number (
value bus.STATUS.speed), a toggle a bool variable of the part, a telltale a bool message. A block with no ui gets one, with a screenMain.
Select the block: its panel lists its ui — each screen, layout and element.
Click one for its form, made from the word's own entry (the same as
completion and hover): value, enabled, visible (and a telltale's on)
suggest the names the ui can read, of the kind the element shows
(bus.STATUS.speed, ctrl.state, lights), and take any expression; style,
size and align are choices; text, range, unit, format, gap… are
fields. The actions (on press …) are written in the source.
Themes and UI kits#
theme dark and theme light are archec's; colors { … } changes some. A
theme of your own is a constant record, base the theme it starts from:
component Panel "Front panel" {
ui {
theme ACME
screen Main "Main" { label hello { text "Hello" } }
}
}
/// Ours: light, with the company's blue.
const ACME = { base: "light", primary: "#0d47a1", background: "#fafafa" }
A UI kit of the Arche Store ships one: theme "acme/automotive-dark" takes the theme of the kit the project uses by that
name. Kits are themes only for now.
Requirements and translations#
satisfies REQ-HMI-3 on a screen or an element traces the requirement to the UI.
Texts are translated like every text of the model: "Driving" fr "Conduite"
where it is written, or a translations fr { "Driving" "Conduite" } block.
Seeing it: archec ui#
archec ui main.arche # the screens, element by element, with what each reads and does
archec ui main.arche --json # the resolved model
The build writes the same model to ui/<component>.json in the Arche project.
It is the one thing previews, simulators, tests and code generators read — the
UiModel type in src/ui.ts: the device and theme, each screen's
element tree with sizes, every binding as a small expression tree and as
JavaScript, every action as checked statements, the navigation, the texts with
their translations, and where each element is written.
The autonomous car's HMI has one: examples/autonomous-car/components.arche.
Seeing the screens: archec ui preview#
archec ui preview main.arche # screens.html: every screen, drawn
archec ui preview main.arche --screen Settings # one
archec ui preview main.arche --png screens.png # a picture of them (headless Chrome)
One page, nothing to install, no external URL: each screen and dialog of each ui
at the display's size (scaled to fit), the overlays over them, in the theme's
colors. Each element shows a sample value: the model's own first values (each
part's variables and state at the start of a run of the browser simulator — the
car's mode label says Mode: Boot), its constants, and for a message not received
yet a value within its type's range. Click an element: the page says what it
is, what it reads (value bus.STATUS.speed, its unit), what a press does, and the
file and line it is written on.
--png asks Chrome or Chromium (--chrome <path>, $CHROME, else the usual
places). archec doc --html shows the same screens as figures, in a chapter User
interface.
In VS Code, UI Preview (the button in the editor's title bar of a model with a
ui, or Arche: UI Preview) shows the screens beside the editor and draws them
again as you type; a click on an element shows its line.
Simulating them#
The screens run with the model in the browser simulator:
archec sim main.arche --html sim.html— the page shows the screens above the block diagram. Their values come from the run: the part's variables and state, the last message each of its ports received, the parts beside it. A press sends its message into the run, where the port's links take it;gotoandbackmove between the screens. Engage on the car's dashboard: the ADCU goes Autonomous, and the mode label says so.- VS Code ▶ Run (the diagram's Run, Simulate): the UI Preview opens beside and shows the screens live — press the buttons there.
- The store's "Try it" page of a package with a
ui: the same.
A test drives them with no page#
The runtime under all of them is headless (src/uiruntime.ts):
it holds the screen shown and the way back, works out what each element shows from
a value source, and runs presses and changes. It interprets the model's expression
trees — no eval — so it runs in a webview, in a page, or in a test:
const { buildUi, createUiRuntime } = require('arche-lang');
const rt = createUiRuntime(buildUi(program)[0], {
values: (key) => values[key], // 'ctrl.state', 'bus.STATUS.speed', …
send: (port, message, fields) => sent.push({ port, message, fields }),
});
rt.screen; // 'Driving'
rt.element('engage'); // { text: 'Engage', enabled: false, visible: true, … }
rt.press('engage'); // { ok: false, why: 'Driving.row1.engage is disabled' }
rt.change('Settings.lights', true);
rt.snapshot(); // the screen, the way back, every element shown
uiSimLink().create(model, simProgram, live) is the value source and the send
over the live browser simulator (src/websimlive.ts).
Testing a UI#
A test case drives the screens as the driver would, in its do and expect lines
— the element as the part with the ui, then its id (hmi.engage), or Screen.id
when two screens have one of that name:
test-plan TP-UI "Driver HMI, on the screen" {
case TC-UI-01 "Engage, then take over" {
step "Ready" {
wait 5ms
expect hmi.engage.enabled
expect hmi.mode.text == "Mode: Ready"
}
step "Engage" {
do press hmi.engage
wait 5ms
expect hmi.mode.text == "Mode: Autonomous"
expect sent("ENGAGE_REQ") == 1
expect not hmi.mrm.on
}
}
case TC-UI-02 "Details and back" {
step { do press hmi.details; expect hmi.screen == Details }
step { do press hmi.back; expect hmi.screen == Driving }
}
}
| Line | Does |
|---|---|
do press hmi.engage |
presses it (long-press, release the same): what its on press does — a send into the run, a goto, a back. A press on an element hidden, disabled or not on the screen shown fails the case, saying why |
do change hmi.limit 80 |
a toggle, slider, select, input or stepper set (True, "text", a constant): its variable, its on change |
expect hmi.mode.text == "…" |
what an element shows: text, label, value, display (formatted, with its unit), enabled, visible, on (a telltale), shown (visible, on a screen shown), options |
expect hmi.screen == Details |
the screen shown (a screen by its name, or "Details") |
expect within 50ms: sent("ENGAGE_REQ") >= 1 |
the messages sent so far, a press's among them |
They go in a timeline too (at 5ms do press hmi.engage), with the rest of the
case's Python (adcu.state == "Manual"). The checker knows them: a name that is no
element, a part with no ui, a change of a button is an error (ui-test), as you
type.
Where they run. Arche's Python simulator has no screen, no enabled, no
text formatting: a case with a ui step runs on the browser simulator (the
model's behaviour in node, src/websimrt.ts) with the ui's runtime over it, the
same pair as archec sim --html and ▶ Run. archec test runs those cases there
and the others in arche-cli, in one report (and with no arche-cli at all when
every case it runs has a ui step); --sim web runs every case there. In the
Arche project the ui lines are written as Python that stops the case, saying
that it runs on the browser simulator — run from Arche, such a case fails, it
never passes for nothing.
Generated. archec gen --target tests writes, beside the transitions and the
limits, a plan TP-GEN-UI: a case per button — the presses that bring the ui to
its screen (the shortest way by gotos from where it starts), a wait and an
expect … .enabled when the button has a condition, the press, then what it must
do (its sent("MSG"), its screen after a goto or a back) — and a case per
screen, reaching it. A screen no goto reaches is written with a TODO and listed in
tests/generated.allow, as the transitions nothing brings.
In CI. archec ci has a ui gate: a binding, a press or a ui step that does
not hold fails it — a message renamed while a button still sends the old name
(ui-binding), a press nothing receives (ui-dead-press). Its test gate runs
the ui cases on the browser simulator even when arche-cli is not there.
Golden screenshots#
archec ui snapshot main.arche --update # ui/screenshots/Hmi/Driving.png, Details.png
archec ui snapshot main.arche # each screen drawn again, compared
archec test main.arche --ui-golden # the cases, then the same comparison
Each screen and dialog is drawn as archec ui preview --png draws it (headless
Chrome, the sample values) and compared pixel by pixel with its golden, committed
beside the model. A pixel whose channels are within 24 of the golden's is the same
(a text's antialiasing); a screen with more than --tolerance percent of its
pixels changed (0.1 by default) fails, and its diff is written —
arche-out/.archec/ui-diff/Hmi/Driving.png, the golden faded and what changed in
red, with Driving.now.png beside it. When the change is meant, --update. No
Chrome: the comparison is skipped and said so. Goldens drawn on one machine may
differ on another (its fonts): make them where the comparison runs.
The web target: gen --target ui-web#
archec gen main.arche --target ui-web -o out # out/ui-web/index.html, ui.js, ui.css, ui.json
A folder that runs on its own — open index.html, or serve the folder. By default
the values come from the model, simulated in the page in real time (the system the
ui's part is in): it boots, the buttons send into the run. ui.json holds the UI
models, the sample values and the simulator's program, for other tools.
With index.html?ws=ws://host:port the values come from a WebSocket instead —
the real product, a bridge to a CAN bus, a test bench. One JSON object per message:
| From | Message | Means |
|---|---|---|
| the server | {"type":"value","key":"bus.STATUS.speed","value":72.5} |
a ref's value now (key: a key of the model's refs) |
| the server | {"type":"values","values":{"ctrl.state":"Ready","bus.STATUS.speed":72.5}} |
several at once |
| the page | {"type":"hello","uis":["Dashboard"],"keys":{"Dashboard":["bus.STATUS.speed","ctrl.state","lights"]}} |
on connecting: the keys it reads |
| the page | {"type":"send","ui":"Dashboard","port":"cmd","message":"STOP","fields":{"hard":true}} |
a press sent a message (one that is one value: "fields":{"value":true}) |
| the page | {"type":"set","ui":"Dashboard","key":"lights","value":true} |
the UI set a variable of its part (a toggle) |
Any message may carry "ui" to name one ui of several. The page reconnects when
the socket closes.
Code for a device: ui-lvgl and ui-slint#
archec gen main.arche --target c -o generated # the parts, as C
archec gen main.arche --target ui-lvgl -o generated # the ui, for LVGL 9, beside it
archec gen main.arche --target ui-slint -o generated # the ui, in Slint, as a Rust crate
LVGL (ui-lvgl/): ui_<id>.c/.h for each ui, written again at each run, and
ui_<id>_task.c, written once — yours.
- Each screen is an LVGL screen, a
dialoga box over it (onlv_layer_top), anoverlayonlv_layer_toptoo, over every screen.row,columnandgridare flex containers,stackchildren centred on one another; the theme's colors are the screens', the text's and the buttons'. ui_<id>_create(&part)builds them for the part the ui belongs to (its C struct of--target c);ui_<id>_update()reads every binding again — call it in the ui's thread beforelv_timer_handler(). A variable is the part's (self->lights), a message is the last one received: the part keeps the last of each message its ui reads (arche_ui_bus_statusin its struct, set bydashboard_ui_got_bus_status(), which the wiring calls as each one comes). A state or a variable of a part beside it (ctrl.state) is inui_<id>_peers: with a deployment the wiring copies it from that part when it runs in the same binary; else setui_<id>_peers_hook(a real product gets it over a message).- An event is a callback:
send cmd STOP(true)calls the part's send function — the one the C target writes (dashboard_send_cmd_stop) —,goto/backa navigation stack (ui_<id>_goto,ui_<id>_back, and each screen'son enter/on exit),lights = not lightsthe part's variable; a toggle, slider, select, input or stepper bound to a variable sets it as it changes. ui_<id>_task.cis the ui's thread:lv_init, the display (an SDL window on a host withLV_USE_SDL, else a buffer and the flush callback your panel's driver gives), the loop. A binary of a deployment built with-DARCHE_UIstarts it from<binary>_start.
Slint (ui-slint/<id>/): a Rust crate. ui/<id>.slint has a global <Id>State — a
property for each name the ui reads, the screen shown, a callback for each event — a
component per screen, dialog and overlay, and the <Id>Window; the bindings are Slint
expressions over the global. src/<id>_glue.rs is the model behind it: Received (each
message the ui reads, each part beside it), Sent (each message it sends), <Id>Ui
with the values, receive(), apply() to the window, and the events in Rust;
bind() installs the callbacks. src/main.rs, Cargo.toml and build.rs are written
once: a host binary that feeds Received from a channel and prints each Sent — give
it your bus.
What each makes. Every element word has an entry in each target's list
(src/gen/uitargets.ts); one a target makes only in part is a
note of archec gen, never dropped:
| Element | LVGL | Slint |
|---|---|---|
row column grid stack spacer |
flex containers | layouts |
label button toggle slider select input |
lv_label, lv_button, lv_switch, lv_slider (its step), lv_dropdown, lv_textarea |
Text, Button, Switch, Slider, ComboBox, LineEdit |
gauge |
lv_arc, its value in it |
its value and a ProgressIndicator (no dial in Slint) |
bar chart |
lv_bar, lv_chart (a point each window/60) |
ProgressIndicator, a Path the glue draws |
telltale |
lv_led and its text, hidden while off |
a dot and its text, while on |
stepper |
lv_spinbox with − and + |
SpinBox (its step 1) |
image |
lv_image of the file through LVGL's file system (UI_IMAGE_DRIVE) |
Image (@image-url, beside the .slint) |
list table |
their fixed options, columns; a bound value's items are yours | the same |
custom |
its lvgl code |
its slint code |
release is a click in Slint; a long press is not made there (a note). Texts are in
the language they are written in.
The display. With no display in the ui, a display part beside the ui's part — in
its system, or in the platform of its deployment — gives the size, the density and the
input: the store has an ILI9341 (320×240, touch), ST7789s (240×320, 240×240), the
SSD1306 (128×64) and an HDMI display (boards). The rover template
(archec new --template rover-esp32) has a status screen on an ILI9341.
On a Linux board: Qt Quick (--target ui-qml)#
archec gen main.arche --target ui-qml -o generated
cd generated/ui-qml/Dashboard
cmake -B build && cmake --build build && ./build/dashboard_ui # C++, Qt 6.5+
python3 main.py # or PySide6
Each ui { } becomes a Qt Quick application in ui-qml/<component>/, written
from the resolved model (never from the source):
| File | What it is |
|---|---|
Main.qml |
the window at the display's size: a StackView of the screens (goto shows one, back to it when it is under the current one; back pops), a Popup for the dialogs, the overlays over everything, the theme's colors |
<Screen>Screen.qml |
a component per screen (<D>Dialog.qml, <O>Overlay.qml): row, column, grid as RowLayout, ColumnLayout, GridLayout; on enter / on exit on StackView.onActivated / onDeactivated |
uimodel.h, uimodel.py |
UiModel, the object the bindings read: a property per name the UI reads (bus.STATUS.speed → bus_STATUS_speed), in C++ (Q_PROPERTY) and for PySide6 |
uibridge.h, bridge.py |
the bridge to the product (below) |
main.cpp, CMakeLists.txt, main.py |
the application — written once, then yours |
README.md |
the properties and the protocol |
The elements are Qt Quick Controls (the Basic style, with the theme as its
palette): label a Label, gauge an arc on a Canvas, bar a ProgressBar,
chart a sparkline on a Canvas over its window, telltale a lit Rectangle
shown while its condition holds, image an Image, list a ListView, table
a TableView (a column per field of columns), button a Button (style danger in the theme's danger color), toggle a Switch, slider a Slider,
select a ComboBox, input a TextField, stepper a SpinBox, and custom
its qml block, handed value. Each has its path in the UI model as its
objectName (Driving.column0.row1.stop): what a test finds it by.
A binding is a QML binding on root.model.<name>; an event is the checked
statements as JavaScript: root.model.send("cmd", "STOP", { "hard": true }),
root.nav.goto("Settings"), root.nav.back(), root.model.lights = !root.model.lights.
A control bound to a variable of its part sets it when it changes.
What QML cannot show is said in a note, never dropped: a custom with no
qml block (a placeholder is shown), an image to copy next to the QML, a table
whose columns are not known (shown as a list), a SpinBox's whole numbers,
translations (the texts are in the language of the model).
The bridge#
On a Raspberry Pi or a Jetson, the UI talks to the rest of the product through
UiModel: set its values, connect to its sent signal. Two bridges are written:
- WebSocket, JSON text frames —
ARCHE_UI_WS=ws://host:8765(C++ when Qt WebSockets is installed, and Python):- in
{"type":"value","key":"bus.STATUS.speed","value":42}sets a value the UI reads; - out
{"type":"send","port":"cmd","message":"STOP","fields":{"hard":true}}for a press; - out
{"type":"value","key":"lights","value":true}when the UI changes a variable of its part.
- in
- SocketCAN (Python:
pip install python-can cantools) —ARCHE_UI_CAN=can0 ARCHE_UI_DBC=bus.dbc python3 main.py, with the DBC ofarchec gen --target dbc: a received frame sets the keys its message is read by (uimodel.RECEIVES, by signal name; a fielda.bis the signala_b), a press is encoded by its message's name. The state or a variable of a part beside the display is not on a bus: it comes over the WebSocket.