A declarative retained user interface for sysl, for a machine with a heap.
dependencies {
syslui { git = "github.com/sysl-lang/syslui", version = "0.1.7" }
}
The presentation is not in here. What the toolkit produces is a block of premultiplied ARGB32
pixels, and putting those on a screen is somebody else's job — syslui-sdl
is that somebody for a window, on a desktop and on a phone. It is a separate package because a link
directive is never pruned: SDL3 has no business on the link line of a program that draws into a
panel, which is the same argument that makes SDL3 four packages in this org rather than one.
It began as a probe — card 0203 asks six questions about whether the architecture decided in
0168 survives contact with the compiler — and all six are answered. What is here now is a styling
layer built on top of those answers: chainable modifiers, a view whose appearance is decided while it
is drawn, and animation. The API is still moving.
imui is the sibling for a panel on a microcontroller and is not replaced by this. It refuses a
retained tree on purpose, and the split between the two is by application size rather than by
device.
Desktop first, mobile once it works well there, embedded only maybe.
Two hundred and fifty-nine tests, sysl test . green — and all six of card 0203's questions answered.
-
A modifier chain works.
text("hi").padding(8).background(red)— the modifiers are trait defaults onViewreturning&View, with&selfreceivers so a wrapper stores the child's box rather than a copy. Object safety admits them becauseSelfappears nowhere but the receiver. -
The erasure survives a wrapper. A leaf, a modifier chain and a fixed frame meet in one
Buf[&View]. The Rust failure mode — a default returning a box needsSelf: Sized, so it is unavailable on the trait object — does not occur. -
weak Fn() -> unitis a working dependent list, and the drop is the unsubscribe. A dependent whose last strong reference has gone is swept on the next notify, so no teardown path in the framework has to remember to unhook. -
State that outlives a rebuild lives in the signal, and the view stays stateless. A scroll offset in a
&Signal[int]survives the entire tree being thrown away and built again — verified by building a different tree that shares only the signal. This was the question most likely to move the architecture, and it did not. -
One layout pass works — constraints down, sizes up, the parent places.
-
A rebuild costs about 22 ns and one allocation a node, which is nothing, and the drawing costs four orders of magnitude more. Measured through
syslui-demo --bench, a real frame at 900×660 with a counting allocator, at three list lengths:rows boxed nodes rebuild + rasterize upload allocations a rebuild 30 92 2.8 µs 4.71 ms 65 µs 124 300 632 14.6 µs 4.65 ms 66 µs 664 3000 6032 126.9 µs 5.21 ms 64 µs 6064 Three things worth reading off that table. The rebuild is linear in the node count and free — 0.76% of a 60 Hz frame at three thousand rows. The frame is nearly flat in the list length, because a row outside the clip is measured and not painted; before culling, three hundred rows cost 7.74 ms against twenty rows' 4.98 ms, and now the two are the same to within noise. And the upload is not the problem anybody expects it to be: 2.3 MiB across to the GPU every frame is 0.4% of the budget.
What is left is the rasterizer, and it is almost all text — the same window with no list at all draws in 0.51 ms. The demo holds a vsync-locked 120 fps.
So the per-frame arena the mobile survey argued for is refused, and by a wider margin than before: allocation is now three parts in a thousand of a frame.
column(spacing = 10):
text(s"count: ${count.read()}").padding(6)
row([
button("increment", () -> count.set(count.read() + 1)),
button("decrement", () -> count.set(count.read() - 1))
], 10)
scroll(column(rows.map(r -> text(r).padding(3)), 0), offset, 420)
The block fills the first parameter no written argument took, so naming spacing ahead of it still
leaves the block to children. A for cannot go inside one — a block is a list of expressions —
so a list whose length is known only while running is named with sysl.seq's map and handed to
the container whole, rather than accumulated into a Buf beside the call.
View is an ordinary object-safe trait and children are []&View, so the tree is erased and kept
alive by ARC. There is no reconciler: diffing an erased tree would have to ask a node what type
it is, which object safety deliberately refuses, so a write to a Signal[T] notifies exactly what
read it instead. That is where SolidJS, Svelte 5, Angular and Swift Observation all landed. SwiftUI
encodes the whole tree in the static type of body and pays for it with associated types and an
opaque result type; that buys no box per node, which is worth having on a microcontroller and worth
very little here.
A tree is built before anything knows where the pointer is — that is what a rebuild is, a function
of the application state alone — so a node cannot be handed the colour it will need. Immediate mode
does not have this problem: imui draws and tests the pointer in the same call, so a hover effect
there is an if.
So a node carries every appearance it might take and chooses while painting, which is the only
moment a view knows its rectangle and can ask the canvas about it. That is SwiftUI's ButtonStyle
exactly, arrived at from the other direction:
reactive(i -> text(label)
.foreground(WHITE)
.padding(8)
.background(if i.pressed() then sunk else lifted, 6)).on_tap(f)
measure uses the resting state and never another, or a style a pixel wider when hovered would
relayout the row under the cursor and shove its neighbours out from under the pointer.
The modifiers are padding, background(color, radius), border(color, width, radius),
foreground(color), frame and on_tap. A border is drawn inside the rectangle layout already
settled on, so adding one moves nothing. Text colour is canvas state rather than a parameter on
text, which is what lets a styled view set the ink around whatever it wraps.
switch, checkbox, radio, slider, progress, meter and divider, in widgets.sysl. Every one of
them is the same four things: a size it asks for, some filled rounded rectangles, a question put to
the canvas about the pointer, and a phase easing toward the answer. There is nothing else, because
Canvas offers nothing else — which is what lets a switch drawn as two rounded rectangles be a
switch on a phone as well as on a desktop.
A control owns no state. A &Signal[bool] is passed in, read while painting and written when
tapped, so a rebuild between those two moments changes nothing. That is scroll.sysl's finding
applied everywhere, and it is what makes a control safe to throw away sixty times a second.
Three of them are worth singling out:
- A checkbox is a
Reactivewrapping aRowwrapping a state-dependent leaf, so pointing at the label lights the box. No widget had to be told about another; it is the styling layer composing somewhere other than a button. - A radio group is a
columnof options over oneint, and there is noRadioGroupview. The mutual exclusion it exists for lives entirely in the signal its options share, so there is no state in which two are on and nothing anywhere has to clear the previous one — which is also why an option is callable on its own and can sit in a different half of a form from its siblings. - A slider is the one thing that reads the pointer's position, because a hit region carries no
coordinate and a control whose value is a coordinate cannot be served by one. So
Canvasgainedpointer(), the drag happens duringpaint, and the write is guarded on the value actually changing — an unguarded one would mark every frame dirty for as long as a pointer rested on a slider nobody was moving.
A slider is generic over its value, so one function serves an int and a real, and the value
is always a whole number of steps from lo:
slider(r, 0.0, 1.0, 260) // a real, continuous
slider(n, 0, 100, 260) // an int, a step of one
slider(n, 0, 100, 260, step = 2) // an int, in twos
slider(cutoff, 20.0, 2000.0, 260, step = 10.0) // Hz, in tens
slider(threshold, 0.0, 1.0, 260, step = 0.01) // a fraction
slider(hold, 0.1, 5.0, 260, step = 0.1, id = 3) // seconds, and the arrow keys once tapped
A step that does not divide the range leaves hi unreachable — the far end is the last step that
fits, as an HTML range input's is — and a real slider given a step of 0.0 follows the pointer
with no grid at all. The step is step: T = T.zero(), and zero means the finest grid T
holds: continuous at real, one at int. The keyboard is opt-in: given an id, a tapped slider moves a step
per arrow and to either end with Home and End, and its knob is lit while it has the keys. A slider
with no id draws exactly as it did before it was generic.
A meter is a reading on a scale with the stretch of it that counts as right marked — a tuner's cents, a balance control's centre-zero, a level against the mark it should reach:
meter(cents, -50.0, 50.0, 380, band = 5.0) // in tune within five cents
meter(level, 0.0, 1.0, 380, band = 0.1, target = 0.75, role = .Accent) // a level and its mark
Nothing in Canvas draws at an angle, so the needle cannot turn: the scale is a strip with ticks at
the tenths and major ones at both ends and the target, the band within band of target is shaded
in the theme's success, and the needle is a bar standing across it. It eases toward the reading
as a progress bar's fill does, so a jittery measurement reads as a needle settling; it takes the
success colour while the reading is inside the band, edges included, and the role's colour
outside it. A reading past either end pins to that end, and one that is not a number draws no needle.
A Theme is a palette and a handful of metrics: six role colours, a text colour for labels on filled
controls, ink, inert, ground, panel, edge, alt, a corner radius, a padding, three lifts, a soft mix
— and a table's look, a grid and a scale of text sizes (below). Every
control reads all of its appearance off it and computes none of it, which is the whole test of
whether a theme is finished.
It lives on the canvas, for the same reason the pointer and the phases do: the tree has nowhere to
keep it. A node is built before anything knows what it will be drawn into, so a colour cannot be
handed to it at construction. And it is a stack rather than a field, so a subtree can be restyled
— the discipline ink/unink and clip/unclip already keep.
switch(on) // the theme's primary
switch(on, .Success) // a role: green here, green in every theme
button("Delete", drop, .Error)
panel.restyle(t -> light()) // this subtree, and everything under it
button("Brand", f).tinted(0xE2007A) // a colour the palette has no name for
Themed holds a function Theme -> Theme rather than a theme, which is what makes restyles
nest: a wrapper storing a whole theme could only replace one, and could not have known what the
theme above it did anyway. measure pushes as well as paint, and that is load-bearing — pad and
radius are in the theme, so a restyled button is a different size.
A caller names a role and never a colour, because a caller cannot see the theme. A hex literal
at a call site is a colour that will still be there when the light theme is not. Six roles is what
every design system converges on and they are not interchangeable: Error and Warning differ in
whether the action can be undone.
Four shapes × six roles = twenty-four appearances out of ten numbers, and every one of them moves when the theme does.
| variant | what it is |
|---|---|
Filled |
the colour is the ground, label in on_fill. The loud one; one to a screen |
Outline |
an outline and a label in the colour, and no ground at all |
Dashed |
an outline that is dashed — says "not yet" where a solid one says "no" |
Soft |
a muted ground mixed from ground toward the colour. The one that scales |
Outline fills nothing, ever, and that is a constraint rather than a taste. Canvas has no
alpha, so a "transparent" ground would have to be painted in whatever is behind it — and a view does
not know what is behind it. Its hover is carried by the outline and the label brightening and the
outline thickening, which needs no such knowledge and is why it is the one variant that can sit on
an unknown surface.
Canvas.stroke gained a dash for the dashed one: one number, not a pattern, meaning that many
pixels on and that many off. A general dash array would be a slice a backend has to keep alive past
the call, and what a dashed border is for is saying "provisional" at a glance.
Text has a size per subtree, in two layers. Underneath, .font_size(pt) draws and measures
everything inside it at pt; on top, .text_style(style) names one of the theme's four sizes,
each a multiple of the size the canvas was made with — so app(..., size = 18.0) scales the
headings with the body text, and a .restyle moves every heading under it.
text("E2").font_size(64.0) // one-off: a tuner's note name
text("Settings").text_style(.Title) // a role: however big a title is here
panel.restyle(t -> t with { text = t.text with { display = 3.0 } })
| style | multiple of the base | at 15 |
|---|---|---|
Display |
2.5 | 37.5 |
Title |
1.5 | 22.5 |
Body |
1.0 | 15 |
Caption |
0.8 | 12 |
Both passes see the size, which is the half a colour never needed: a row holding a big label is
laid out by the big label's metrics and painted by them, so nothing is clipped. Sizes nest — the
innermost wins, and a sibling after the subtree is back at what was in force before it — and an
overlay is painted at the size in force where it was raised. On the canvas it is a stack like the
theme's (push_font_size/pop_font_size, font_size(), base_font_size()), every member
defaulted, so a backend that ignores it draws everything at one size and still compiles.
PlutoCanvas reads the face's metrics once per size and remembers each run's width per size.
There is no weight. A canvas draws with one face, and a heading made bold by smearing it would be
a fake; a size is something every backend can honestly do. A size is a real, so it is written
64.0 — an integer literal is not one.
Row and Column answer what comes after what. A grid answers where.
A row gives each child the width that child measured to, so a form laid out as rows has its fields starting wherever their labels' text happened to end — which looks like an accident, because it is one. A grid divides the width into equal columns and a child says how many it takes, so two children in different lines line up because they were given the same fractions rather than because their contents were the same length.
grid():
text("volume").padding(4).cell(4)
slider(volume, 0, 100, 260).cell(16)
text(s"${volume.read()}%").padding(4).cell(4)
text("progress").padding(4).cell(4)
progress(done, 380, .Success).cell(16)
text("").cell(4)
Two counts, chosen to be complementary rather than coarse and fine. Both give halves, thirds and sixths; each gives one thing the other cannot.
| halves | thirds | quarters | fifths | sixths | eighths | tenths | |
|---|---|---|---|---|---|---|---|
| 24 | 12 | 8 | 6 | — | 4 | 3 | — |
| 30 | 15 | 10 | — | 6 | 5 | — | 3 |
COLUMNS_24 is the default — quarters and eighths are what an ordinary layout is made of.
COLUMNS_30 is for a layout with fifths in it, which 24 simply cannot express.
60 and 120 are the obvious alternatives and are deliberately absent. Each exists only to hold both families at once, and the price is a count whose spans stop meaning anything at a glance: a span of 40 in 120 reads as a number, where 6 in 30 reads as a fifth. Two small grids you can do arithmetic on in your head beat one large one you cannot.
The count lives in the Theme, for the same reason the colours do: what makes a grid worth
having is that things in different parts of an interface line up, and they can only do that if
nobody chose separately. A section wanting fifths writes
.restyle(t -> t with { columns = COLUMNS_30 }).
.cell(span, offset) skips offset columns first, a line wraps when it runs out, and an unmarked
child takes the whole width. .cell must be the last link in a chain — a wrapper outside it
would answer the grid's question for itself, the same rule that makes .frame() stop a Spacer
being flexible.
The edges are computed as col * (w + gutter) / cols rather than from a column width, so the
rounding falls between columns instead of accumulating at the right-hand end, and the last edge lands
exactly on the width.
A scrollable, sortable-width, striped, tappable table — and it adds no drawing at all. It is a
Column of Rows inside a Scroll under a header that does not scroll, and every one of those
already existed. If a table had needed a new Canvas operation it would have meant the surface was
wrong, rather than that tables are special.
table([col("#", true), flex_col("name"), col("status"), col("value", true)],
rows, offset, 340, Some(i -> select(i)))
What it adds is the one thing containers cannot do for themselves: columns that line up. A Row
gives each child the width that child measured to, so two rows of the same shape produce two
different sets of column edges the moment one cell's text is longer. So the widths are decided once,
by the table, from the header and every cell, and handed down as fixed frames. Three kinds:
col fits its widest cell, fixed_col takes exactly what it is given, flex_col shares out what is
left over.
Cells are strings, not views, and that is a deliberate limit. A cell that could be any &View
would mean measuring a tree per cell to decide a column width, twice a frame, for every row including
the ones nobody can see. Strings go through the canvas's measurement cache, so a column's width costs
one shaping pass per distinct string for the life of the canvas.
That limit buys the thing culling could not: a table builds only the rows that can be seen. Culling stops an invisible row being painted, but the row still has to exist to be culled — and a table's body is built during paint, so 300 rows meant 12,605 boxes made and thrown away every frame. Every row being the same height turns "which rows are visible" into arithmetic, and the rows above and below become two framed spacers so the list keeps its full height for the scrollbar to be a fraction of.
| allocations a frame | frame | |
|---|---|---|
| all rows built | 12,605 | 1.69 ms |
| only the window | 933 | 1.21 ms |
| …at 3000 rows | 933 | 1.33 ms |
A table has more surface than everything else here put together, so its appearance is a
TableStyle block inside the Theme rather than ten more fields on Theme — header ground and
ink, the ordinary row, the stripe, the hovered row, the rule, whether rows are striped, ruled or
highlighted, and the cell inset that decides the row height.
table(cols, rows, offset, 340)
.restyle(t -> t with { table = t.table with { striped = false, lined = true } })
Striping is the theme's, not the call's. A flag on table(...) would be one table's appearance
decided at one call site, which is the thing a theme exists to prevent — and striping and rules are
alternatives rather than companions, since both say "this row ends here".
Until this block existed the header's text was mix(ink, panel, 35) computed inside table.sysl — a
colour a theme could not name and therefore could not change, which is exactly what a theme is for.
Every row paints its own ground, because Canvas has no alpha: a row that wanted to be "the
surface underneath" would have to know what that was. Naming it makes the stripe, the hover and the
ordinary row one piece of arithmetic rather than three special cases.
The hovered row is a Reactive, so it eases in — the same Interaction that drives a button
drives a table row, and nothing new was needed for it.
Everything else here composes downward: a view is given a rectangle and must not draw outside it, a container clips its children, and culling skips what the clip cannot reach. That is exactly wrong for a dropdown, which is anchored to something small and has to spill out over what comes after it.
The answer is a second pass, not a second tree. A view that wants to be above everything doesn't paint itself — it hands the canvas a view and a rectangle, and the frame paints those after the tree. By then every clip has been unwound, so an overlay is unclipped without asking; and because the hit list is last-wins, it is on top for input without asking either. Neither needed a special case.
button("Options", () -> open.set(!open.read()))
.popover(open, menu) // anchored below the trigger, escapes any clip
body.modal(confirming, confirm_panel, Some(cancel)) // centred, everything under it untappable
Use render(tree, c, rect) rather than measure and paint by hand — an application that
painted the tree itself would silently never draw a dialog, which is the same class of failure as
forgetting to subscribe a signal, designed out the same way.
raise_above is the only member on Canvas with no default body. Elsewhere the rule is that not
implementing something costs the feature and never the frame — a backend ignoring visible is merely
slower. A backend ignoring this would draw a frame with the dialog missing, which is a wrong
picture rather than a plainer one.
What is missing, for a stated reason: a modal has no dimmed backdrop. Dimming needs alpha and
Canvas has none — a solid rectangle would hide the interface rather than subdue it. What a modal
must do regardless is stop what's behind being clicked, and a full-size region that swallows taps
does that on its own.
tabs(labels, chosen, i -> panel(i)) — and there is no Tabs view. A tab bar is a Row of
Reactive headers, each a Column of a label over a restyled two-pixel divider, above a rule and
the chosen panel. All of that already existed; if tabs had needed a new View or a new Canvas
operation it would have meant something below was missing.
The panel is a function of the index, so only the chosen one is built — a list of views would build every panel on every rebuild, including the ones nobody is looking at, to show one.
Whether a header is chosen is captured at build time, not read while painting, because the panel below it is also built once. A header reading the signal live would move its indicator on the frame a tab was tapped while the panel waited for the rebuild — one frame of the bar highlighting the wrong thing.
text("title").pad(bottom = 12)
panel.pad(left = 8, right = 8)
text("hi").padded() // however much room the theme gives things
padding(n) insets every side; pad(top =, right =, bottom =, left =) insets the ones you name.
Named arguments are what makes that readable, which is why it's four defaulted parameters rather
than an Insets struct — .pad(bottom = 12) says what it does where .inset(Insets(0, 0, 12, 0))
makes a reader count commas. padded() takes its inset from the theme, the same argument as panel
against background.
There is no margin, and there doesn't need to be one. The difference between a padding and a
margin is only which side of the background it falls on, and the chain decides that:
text("hi").padding(8).background(red) // padding — the ground covers the inset
text("hi").background(red).padding(8) // margin — the ground stops at the text
A second name for the same wrapper would be two ways to write one thing.
column(spacing = 8):
meter(cents, -50.0, 50.0, 300)
scroll(detections, offset).flex() // everything under the meter
text("ok").background(green).flex(2) // …or two shares of it, beside a one-share sibling
A column measures its children, adds up what they asked for, and hands what is left over to the
children that asked for a share of it — n shares each, in proportion. A row does the same on
width. spacer() is the same request from a view with nothing to draw, so a Spacer and a .flex()
view share alike.
- The view is measured as it always is and then painted in the bigger rectangle, and what it does
with the room is its own affair, exactly as under
.frame(): abackgroundfills it, atextis drawn as in any taller rectangle, ascrollmakes it the window. - Nothing spare, nothing extra. When the inflexible children already fill the column, a flexible child gets its natural size and no more; the leftover never goes negative.
- The last link in the chain, as
.cellis: intext("a").flex().padding(4)the column asks the padding, which has no opinion. Outside acolumnor arowit means nothing. - It nests in one pass: a
.flex()column inside a column gets its share, and hands its own leftover to its own flexible children.
A scroll with no height — scroll(child, offset) — is the one that fills. Under .flex() its
window is exactly its share. Anywhere else it is as tall as its content, up to the room it is offered,
so a short list leaves no hole under it. A flexible child is asked its size with nothing to grow into,
so a filling scroll above a footer leaves the footer its room, and two filling scrolls split the space
by weight. scroll(child, offset, height) keeps its height whatever the layout, as before.
The toolkit selects; the application copies. A selection is geometry and state, which is what a
view knows about; the clipboard belongs to the window system, which a view shouldn't reach. sdl3
has clipboard_text / set_clipboard_text, and a program binding ⌘C reads the selection signal and
hands the substring over.
selectable(line, sel) // drag to select, highlight painted behind the run
selected_text(line, sel.read()) // what to put on the clipboard
Two Canvas members do the work, both with default bodies so no backend implements them:
text_at(s, x) gives the byte offset nearest a pixel and text_x(s, at) the reverse. The default
walks characters and measures prefixes — which is why an offset is never inside a multi-byte
character. A hit test that divided a width by a byte count would split an é; there's a test on
exactly that.
The selection is stateless in the view. A Selectable holds no anchor: both ends are recomputed
every frame from where the press began and where the pointer is now. The canvas already knew the
first, because capturing the pointer for the slider meant recording it — so there is no first frame
of a drag to notice and no half-set state to leave behind. press_origin() is what made that
possible.
A click carries a position and a key carries nothing, which is the whole difference between the pointer and the keyboard. A tap can be matched against the frame's hit regions after the fact; a key has to be routed by something that outlived the previous frame, and that something is focus.
text_field(name, id = 1, hint = "your name") // one line
text_area(notes, id = 2, width = 320, rows = 5) // several, no wrapping
Focus is an id the caller picks, not the rectangle everything else is keyed by. A form that
scrolls while somebody is typing moves the field they are typing in, so geometry loses the focus at
exactly the moment it matters most. An id is what tabs and radio already ask for, and it is the
only name that survives a relayout — it must be unique in a window and never NOBODY.
A press anywhere blurs and whatever the press was inside takes focus back while it paints. That is one line in each of two places rather than a rule anybody has to remember: no view knows that another view was clicked, and a click on a button, on a list or on empty ground all put the keyboard away by doing nothing at all.
The press is remembered rather than observed, and a touch screen is what forced that. Focus was
taken by a field noticing pressed(r) while it painted, which works with a mouse only because a
mouse press lasts several frames — a tap is a press and a release arriving between two frames, and
the paint in the middle sees nothing at all. So the down transition records where it landed and
tapped(r) reports it for exactly the frame that follows. On a phone that was the difference between
a field that could be typed into and one that could not, and no test in this package could have found
it: every one of them held the button down.
The work splits three ways, and only the last of the three is in field.sysl:
- What a key does is a pure function.
apply(text, sel, press, multiline)is inedit.sysland can neither draw nor measure nor ask the window anything. The hard half of a text field is not the caret bar, it is what backspace does to a selection dragged right-to-left across a multi-byte character — and a rule shaped like this is one a test can put a hundred cases through without a canvas, a font or a frame. Twenty of the package's tests are exactly that. - Who the keys belong to is the canvas's, in
key.sysl, besideHitsandGrabfor their reason: there is one right answer to "who has the keyboard" and a second implementation of it is a second place for it to be subtly different. Five defaultedCanvasmembers reach it, so a backend with no keyboard draws a field as an ordinary run of text that cannot be typed into. - The text is the application's. A
&Signal[string], like every other piece of state that outlives a rebuild.
A key belongs to exactly one frame, and render is what ends it. A frame loop polls its events
before it ticks its clock, so clearing the keys in tick throws away the keys of the frame about to
be painted; leaving it to the application means the one that forgets repeats every keystroke forever.
render is what every program already calls once a frame, so there is nothing to forget.
A caret is a Selection whose ends are equal, so there is one notion of position rather than
two: moving without shift collapses it, moving with shift leaves the anchor, and typing replaces
whatever lies between. That is also what lets the selection painting and selected_text be the same
code a selectable run uses.
A field and an area are one view. They differ in how many rows they measure to and in what four
keys mean — Enter, Home, End and the vertical arrows — and everything else is shared. Both
scroll to keep the caret in view and neither remembers how far: the offset is computed from where
the caret is, every frame, so text replaced from underneath cannot leave a field scrolled past the
end of what it now holds. Nothing wraps: a long paragraph in an area is one line that scrolls
sideways, because wrapping means measuring candidate breaks over the whole run on every frame.
val logo = load_image("logo.png").unwrap() // PNG, JPEG, BMP, TGA, GIF, PSD, HDR, PIC, PNM
val face = image_from_data(bytes).unwrap() // or from memory, which is what a phone has
image(logo) // at its own size
image_sized(face, 64, 64, Fit.Cover, 32) // cropped square, rounded to a circle
A picture is the fourth thing a Canvas can draw, and the only exception to the rule that keeps
that surface small — everything else is a filled rectangle, a glyph run or a clip, because the
toolkit has to run where there is no rasterizer. Putting a block of pixels down is the other thing
every backend can already do, a plain SDL3 one included; what is still refused is a shape a backend
would have to trace.
Three fits, because there are three answers: Stretch changes the shape, Contain keeps it and
leaves a gap, Cover keeps it and crops. The two that keep it differ by a single comparison, which
is why both directions of it have a test. Only Cover clips, because it is the only one that puts
pixels outside the rectangle it was given.
The pixels are a plutovg.Surface, which is a container rather than a backend — a width, a
height, a stride and premultiplied ARGB32 — and decoding is plutovg's too, since stb_image is
vendored inside it. So an image costs nothing linked that the package did not already carry, and a
backend that is not plutovg reads the bytes and uploads them, exactly as st7796 does with a band.
Corners are a parameter and not a feature: a texture is cut to the shape it fills, so a rounded picture is a rounded rectangle path. The radius is divided by the scale before it is asked for, or a picture squeezed into a rectangle of another shape would come out with elliptical corners.
A container asks c.visible(child_rect) before it paints a child and skips it if the answer is no.
The measure still happens — a column needs each child's height to place the one after it — and the
drawing, which is nearly all of the cost, does not.
This is what makes a long list affordable: a scrolling list of three hundred rows shows about ten, and the other two hundred and ninety used to be painted, clipped away by the backend, and thrown out after all the work of producing them. That was 2.8 ms a frame at 900×660, seventeen per cent of a 60 Hz budget, spent entirely on discarded pixels.
A culled child registers no hit region either, which is a correctness claim rather than a performance one: a row scrolled out of sight must not answer a click where it would have been.
visible defaults to true, so a backend that tracks no clip is correct and merely slower — the
right way round, since a backend that answered false by mistake would draw nothing at all.
Interaction carries two reals rather than two flags, so a style is told how far in a hover is
and the arithmetic that picked a colour picks every colour on the way to it. Animation therefore
costs a style nothing and is not something it opts into.
The phase itself lives on the canvas, keyed by the rectangle — the same trick the hit list plays,
and it works for the same reason. phases.sysl is the store: a linear step toward a target at a rate
per second, interruptible by construction, sweeping whatever nothing asked about during a frame.
Linear rather than eased is a decision rather than a shortcut. An ease-in-out is a curve over a duration, and a phase interrupted half way — which is exactly what a pointer crossing a button twice produces — has no such duration any more. Moving at a rate is interruptible: wherever the value has got to is a legitimate place to head back from.
What it costs: a view that moves loses its phase, because its key changed. For a button in a
static chrome that is never; for a row in a list being scrolled it is every frame, so a hover effect
on a scrolling row snaps rather than eases. That is the honest boundary of keying by geometry, and
the fix — were it ever worth one — is an identity the application supplies, which is the retained
element card 0168 spent its length refusing.
A backend with no clock answers the target outright, so a tree drawn through one is drawn settled. That is what makes animation something a backend opts into rather than something every backend owes.
A scroll moves three ways, and all three write the one &Signal[int] the application passed in:
- Its bar, dragged by the thumb, which is grabbed by its middle and follows the pointer anywhere.
- A finger dragged anywhere in the window. A press becomes a scroll once it has travelled more than eight points down from where it landed, and from then on the content follows the pointer by exactly its travel, slop included, stopping at both ends. Under the slop it is still a tap, so a finger that wobbled on a button presses it.
- The wheel and a trackpad, over the window. A glide made of fractions of a point adds up rather than rounding away.
Who wins is decided by asking order. A list asks after painting its content and its bar, so a control that claimed the pointer when the press landed — a slider, a text field, a selectable run, the bar's thumb — keeps it however far the finger travels, and a list inside a list asks before the one around it and takes both the drag and the wheel. The cost is one frame: the content lands under the finger on the frame after it moved. A list whose content fits takes neither, so both reach a list around it. The list scrolls vertically only, so a sideways drag never starts one and is left for whatever inside wants it.
A tap now lands at the release, not at the press, because only then is it known that the press was not the start of a drag — a finger scrolling a list of buttons must not press the one it happened to land on. And a press that wanders off what it went down on and lets go elsewhere taps nothing.
Two members on Canvas carry it, both with defaults so every other backend still compiles:
drag(*self, r: Rect, slot: int, slop_x: int, slop_y: int) -> (int, int) = (0, 0)
wheel(*self, r: Rect) -> (int, int) = (0, 0)
drag claims the pointer for r once the press has passed the slop and nothing else holds it, and
answers how far the pointer has moved since r last asked. wheel answers the whole points the wheel
turned while the pointer was over r and takes them, which is how the innermost list is the only
one to move. Both answer in the same sense — positive y moves the content down — so a view reads a
finger and a wheel alike. A backend without them has lists that scroll only by their bar.
What a frame loop owes: release(x, y) for a button coming up, in place of point(x, y, false)
and of calling the hit list itself; spin(dx, dy) for each wheel event, in points and in that sense,
with the platform's natural-scrolling setting already applied; and render, which forgets a turn
nobody was under the pointer to take. The recorder has the same two, which is how the tests drive it.
There is no fling. A finger lifted mid-drag stops the content where it is; momentum would need a velocity remembered per list between frames, and the canvas has nowhere keyed to keep one yet.
PlutoCanvas draws a frame into a PlutoVG surface, and PlutoVG is here because of corners: SDL3
has no shape or path API at all, the triangle fan that would draw a rounded rectangle is not
antialiased, and a rounded border is an annulus, which is not convex and cannot be drawn that way
regardless. PlutoVG vendors FreeType's ftgrays rasterizer, carries its own C99, and needs nothing
installed — which is why the same backend serves a desktop and a phone, where cairo does not.
It draws; it does not present. What comes out is a surface of premultiplied ARGB32 pixels, and getting those onto a screen is the application's: a streaming texture on the desktop and the same on Android, since SDL3 is the presenter on both. That split is what keeps one backend serving two platforms, and it is why this package asks for no window system of its own.
The arithmetic that can be wrong — the half-width inset a centred stroke needs to land inside the
rectangle layout settled on, the smaller radius that inset corner takes, the channel conversion, and
a line height read from a descent whose sign is a matter of convention — is lifted out of the
delegation and tested as ordinary functions, so none of it needs a font to be checked.
A program built by sysl build-c — every Android app, every Pico program, anything whose link
belongs to CMake or Gradle — has no sysl main, so nothing runs module initializers. Reaching
computed module storage from an @exported function is refused for exactly that reason:
'sdl_main' is exported and reaches 'sh.sysl.ui.clock', which is module storage an initializer fills
before the program's own statements run.
The write counter was a boxed &Epoch with a bump method until an Android build said so; it is a
bare var int now, which is laid straight into the object file. The package's own tests cannot
catch a regression here — sysl test links a sysl program and runs the initializers — so what
catches it is a sysl build-c of something that uses the toolkit, which is what
sysl-lang/syslui-android is.
sh/sysl/ui/view.sysl— theViewtrait, the leaves, and the modifierssh/sysl/ui/theme.sysl—Theme,Role,Variant, and theThemed/Panelviewssh/sysl/ui/style.sysl—Interaction,Reactive, andbutton's four variants built on themsh/sysl/ui/widgets.sysl—switch,checkbox,radio,slider,progress,meteranddividersh/sysl/ui/color.sysl—mix,lightenanddarkensh/sysl/ui/phases.sysl— where an animation lives, in a framework with nowhere to put itsh/sysl/ui/signal.sysl—Signal[T]and its weak dependent listsh/sysl/ui/layout.sysl—ColumnandRow, and the leftover aSpacertakessh/sysl/ui/grid.sysl— positioning by fractions of the widthsh/sysl/ui/table.sysl— a scrollable table, built entirely out of the files above itsh/sysl/ui/tabs.sysl— a tab bar, built entirely out of the files above itsh/sysl/ui/overlay.sysl—render, popovers and modals: the second paint passsh/sysl/ui/atom.sysl— application state a module declares, and values derived from itsh/sysl/ui/select.sysl— selecting a run of text with the pointersh/sysl/ui/image.sysl—Image, the three fits, and the one drawing operation that is not a rectanglesh/sysl/ui/key.sysl—Key,Press, and the focus the canvas keepssh/sysl/ui/edit.sysl— what a key does to a string and a caret, as a pure functionsh/sysl/ui/field.sysl—text_fieldandtext_area: the geometry left oversh/sysl/ui/scroll.sysl— where long-lived state lives, and the scrollbarsh/sysl/ui/canvas.sysl— theCanvastrait a frame is drawn through and measured against; a recorder that draws nothing and remembers everything, so the tree can be asserted with no window; andBlind, the smallest thing that satisfies the trait, which is there to say what a backend actually has to writesh/sysl/ui/pluto.sysl— the real backend: a frame drawn into a PlutoVG surfacesh/sysl/ui/input.sysl— where a tap goes: the regions the paint pass collectedsh/sysl/ui/clips.sysl— the clip stack both backends cull against, in view coordinatessh/sysl/ui/tests.sysl— the findings, kept as tests rather than as prose