Ce document est rédigé en anglais, la langue de travail du dépôt, et il est converti tel quel depuis le fichier qui fait foi. Les pages que le site écrit lui-même — l'accueil, la galerie, l'architecture et les guides « je viens de… » — existent intégralement dans les deux langues.
Everything public, in one place. The guide explains how the pieces fit; this says what each one is.
All sizes are in logical points. All colours are Ints holding
0xAARRGGBB — build them with rgb, rgba, web or the theme.
runApp(title, w, h, build) | open a window and run until it is closed |
App(title, w, h) | the same, when you want to set more than build |
run(app) | run one |
snapshot(app, w, h, scale, path) | draw one frame to a BMP, no window |
An App has build, overlay, onKey, onStart and onStop. Every
program also understands --snapshot <file> [scale] [w h] and
--window-id <file> on its command line without being told to.
state(initial) | a Cell<T> holding a value |
cell.get() / cell.set(v) | read it, write it |
cell.update({ v -> … }) | read and write in one go |
invalidate() | something changed that is not in a cell |
revision() | how many times anything has been written |
column(kids) | stacked downwards |
row(kids) | stacked across |
stack(kids) | one on top of another, last nearest the viewer |
box(kid) | one child, somewhere to hang a background or a padding |
card(kids) | a surface with a border, a corner and room to breathe |
raisedCard(kids) | the same, with a shadow |
panel(title, kids) | a titled surface with a heading strip |
scroll(kids) | scrolls vertically when the content is taller than the box |
tabView(titles, index, onChange, content) | a tab strip with its content under it |
spacer() | takes everything left over — two of them centre what is between |
gapV(h) / gapH(w) | a fixed gap |
divider() | a hairline across the container |
nothing() | no size, no ink, no answer to the pointer |
label(s) | one line |
caption(s) | smaller and quieter |
heading(s) | a section heading |
title(s) | a page title |
paragraph(s) | wraps to the width it is given |
badge(s) | a small pill — a count, a status, a tag |
link(s, onTap) | a line of text that acts on a click |
banner(level, s) | something to say: 0 plain, 1 good, 2 warning, 3 failure |
button(s, onTap) | with `.kindOf(primary \ | plain \ | quiet \ | danger)` |
iconButton(name, onTap) | a button that is only an icon | |||
menuButton(name, choices, onPick) | an icon button that opens a menu | |||
checkbox(s, on, changed) | a box that is ticked or not | |||
radio(s, on, chose) | one of several, shown as a dot | |||
toggle(s, on, changed) | the same choice as a switch | |||
segmented(choices, index, onChange) | a row of choices, one pressed | |||
select(choices, index, onChange) | a dropdown, opening a menu of them | |||
slider(value, lo, hi, changed) | a value chosen by dragging | |||
stepper(value, lo, hi, step, changed) | a number with a minus and a plus | |||
progress(value) | a bar showing a fraction, taking no input | |||
field(value, hint, changed) | a line of text the user types | |||
secretField(value, hint, changed) | the same, showing dots | |||
editor(value, hint, rows, changed) | text over several lines, wrapped | |||
tabs(titles, selected, chose) | a strip of tabs on its own | |||
icon(name) | a shape drawn from strokes |
Every control reports what it would become, not what it is: a checkbox
hands you true when it is off. Nothing flips itself; the tree is rebuilt
from your state.
field and editorThey are two widgets rather than one with a height, because four keys mean different things:
field | editor | |
|---|---|---|
| Return | accepts — calls .tappable's handler | puts in a newline |
| Home, End | the ends of the text | the ends of the row the caret is on |
| Up, Down | nothing | the row above and below, keeping the column |
| a paste with newlines in it | flattened to spaces | kept |
Everything else is shared, and shared in the source and not just in
behaviour: the selection, what a keystroke replaces, what Backspace takes,
what a double click takes and what a triple click takes are all written once
and work on character offsets. rows is how tall an editor is in lines; past
that it scrolls, with the wheel or by the caret leaving the box.
An editor wraps to the width it is given. A word longer than the column is cut rather than left to overflow, and a click on either side of that cut puts the caret where it was clicked — which is not free, and is the one place in the text machinery where an offset alone does not say which row it is on.
.wrappingLines(false) turns that off: the only breaks are the ones the author
typed, and the editor scrolls sideways to follow the caret along a long line —
by the caret, or by a trackpad, or by a wheel with Shift held. Use it for
code. A line of code that folds reads as two lines: an expression breaks in
the middle of itself, and anything counting lines beside the text counts the
wrong ones. Leave it on for prose, which is what it is for.
Not to be confused with a label's wrapping(), which is the opposite default
for a different question — a label cuts itself with an ellipsis unless told to
wrap.
.gutter() gives an editor line numbers down its left side, and
.gutter([LineMark(12, theme().danger), …]) marks lines as well — an error, a
warning, a breakpoint.
editor(source, "", 30, { s -> src.set(s) })
.wrappingLines(false)
.gutter(problems.get().map({ p -> LineMark(p.line, colourFor(p.kind)) }))The numbers are the lines the author typed: a folded line carries one
number and its continuation carries none, because a number in a margin has to
mean what a compiler's :12: means. LineMark.line counts from one, as the
numbers beside it do, and a mark on a line the text no longer has is dropped
rather than drawn — the check that produced it and the buffer it is drawn
beside are two different moments.
The margin follows the editor down and not sideways. Numbers that walked off the left edge on the first long line would take the marks with them, and the marks are what you look at while chasing a long line. Its width comes from how many digits the last number needs, so a forty-line file spends two columns on it and a twelve-thousand-line file still fits.
A tab moves the pen four spaces and draws nothing; every other C0 control and DEL take no room and draw nothing at all. Four spaces is an advance and not a tab stop: a stop's width depends on the column it starts at, which would make the width of a string depend on where the string begins, and the caret — placed by measuring — would come away from the letters, which are placed by drawing.
Go to a line, jump to a search result, comment out the line the caret is on, move to the matching bracket: none of those can be said by handing a widget a new string — they are about where somebody is in it. So an application can hold the caret:
val cur = caretAt(0)
editor(src.get(), "", 30, { s -> src.set(s) }).caret(cur)
// anywhere, from any handler
cur.at = offsetOfLine(12)
cur.anchor = cur.at
invalidate()at and anchor are character offsets, the same ones everything else in the
text machinery works on; they are equal when nothing is selected. Read them
whenever you like — the widget writes them back every time the user moves the
caret. Write them and the caret goes there, clamped to the text rather than
indexed past it, because the offsets an application holds come from a compiler
or a search and the buffer may be shorter by the time they arrive.
Two-way needs a rule for who wins, and it is this: a difference from what the widget last wrote means the application has spoken, and the widget adopts it. A jump also throws away what the caret remembers between keystrokes — the column a run of Up and Down is aiming for — because an offset somebody named says nothing about a column somebody was aiming at.
And a jump lands with a few lines under it, where a keystroke moves the page as little as it can. They are different questions: a caret that moved by one line is where its reader already was, and scrolling further would drag the page under their eyes; a caret the application placed arrives somewhere nobody was looking. Nothing has to ask for this — an offset that came from outside is a jump, and the widget knows which of the two it received.
cur.screen is where the caret is on the screen, in window points, as the last
frame drew it — the point to anchor something to it: a completion list under
it, a type beside it. It follows the sideways scroll, and it is written whether
or not the widget has the keyboard, because something anchored to a caret still
needs to know where it would be.
It is the last frame's, exactly. A keystroke rebuilds the tree inside the
event batch and paints after it, so a handler reading screen during the
keystroke that moved the caret sees where the caret was before it — one
character out. Open what you are anchoring on the frame after the move.
Without a caret, the framework keeps the caret in the state it retains by
identity, which is right for every widget that has not asked. Works on a
field as it does on an editor.
⌘F should land in a search box, and a search box cannot be typed into unless
somebody can say so. The framework keeps the focus for every widget that has
not asked — Tab walks it, a click moves it, Escape gives it back — and an
application can hold it instead:
val searching = focusHeld()
field(term.get(), "search", { s -> term.set(s) }).focus(searching)
// in a ⌘F handler
searching.has = true
invalidate()Read has whenever you like: the widget writes it back when a click or a Tab
moves the keyboard elsewhere, so has the user left my box is a question you
can answer. Setting it to false gives the keyboard to nothing, which is what
Escape does; to move it from one widget to another, ask on the other one — the
one losing it is told.
Any widget that can take the keyboard takes a focus: a field, an editor, a
button, a checkbox, a radio, a toggle.
.coloured([InkRun(start, end, ink), …]) paints stretches of a field's or an
editor's text in colours of their own. [start, end) are offsets into the
text; anything no run covers keeps the ordinary ink. Runs must not overlap and
must already be in order — nothing sorts them, because whatever made them knew
the order and re-deriving it every frame is work done for nobody.
editor(source, "", 30, { s -> src.set(s) })
.coloured(tokens.get().map({ t -> InkRun(t.from, t.to, colourOf(t.kind)) }))An InkRun holds two offsets and a colour and nothing else, so a lexer, a
spell checker and a diff all use the same field and none of them is named in
it. keal-view has no idea what a keyword is and should not acquire one.
Honoured by field and editor and by nothing else: a label cuts itself
with an ellipsis and aligns what is left, so offsets into it stop meaning what
they said, and secretField ignores runs outright — its dots stand one for one
with the characters, so colouring them would draw the shape of the password.
check close plus minus chevron-up chevron-down chevron-left
chevron-right menu dot search play pause warning folder
file trash gear backspace
An unknown name draws a hollow square, so a typo is visible rather than silent.
custom(draw) | a view that paints itself |
underlay(draw) | the same, taking up no room — a background inside a stack |
draw is given a Paint:
p.canvas | a Canvas, already clipped to this view |
p.area | this view's rectangle, in points |
p.theme p.fonts | what everything else is drawn with |
p.hot p.pressed | whether the pointer is over it, and over it with a button down. "Over" does not require the view to take clicks — but it is false wherever something that does has the pointer instead, so while a popup is open nothing beneath it is under the pointer |
p.text(s, r, align, style, col) | one line in a box, cut with an ellipsis if it must be |
p.line(s, x, baseline, style, col) | one line on a baseline; answers where the pen ended |
p.width(s, st) p.lineHeight(st) p.ascent(st) | measuring |
Canvas: clear fillRect fillRound strokeRound fillCircle line
gradient shadow mask plot run push pop clip bounds.
Each answers the same view, so they chain. A newline after ) ends a
statement in Keal, so keep a chain on one line or break it into statements.
.w(v) .h(v) .sized(w, h) | a fixed size |
.least(w, h) | a floor; the content may still ask for more |
.grows() | a share of the room, starting from nothing (CSS flex: 1) |
.growsBy(f) | a named share, on the same terms |
.fills() | what the content needs and a share of what is left |
.shares() | on a container: children divide the room in exact proportion |
.pads(insets) .padAll(v) .padXY(h, v) | space inside |
.margin(v) | space outside |
.gaps(v) | space between children |
.aligned(main, cross) | how children sit along and across the axis |
.at(x, y) | inside a stack: sit here, at your own size |
Alignment values: mainStart mainCenter mainEnd mainBetween, and
crossStart crossCenter crossEnd crossStretch (stretch is the default).
.bg(col) | a background |
.outline(col, width) | a border |
.rounded(r) | corners |
.raised(blur) | a shadow this many points across |
.color(col) | the text colour |
.font(style) .fontSize(v) .bold() .mono() | the text |
.centered() .trailing() | where the text sits in its box |
.wrapping() | wrap instead of cutting with an ellipsis |
.kindOf(v) | a button's family: plain primary quiet danger |
.withIcon(name) | an icon before the label |
.off() / .offWhen(c) | grey it out and stop it responding |
.shownWhen(c) | draw it, or do not |
.keyed(s) | give it a name, so its own state follows it and not its position — required for anything in a list that can be reordered, inserted into or deleted from |
.tip(s) | a note beside the pointer while it rests here |
.pointer(c) | the pointer shape: cursorArrow cursorHand cursorText cursorResizeH cursorResizeV |
.tappable(f) | take a click without being a control |
.dragged(grab, move, drop) | answer the pointer with a drag; a press that never moved is a click |
openMenu(x, y, choices, selected, onPick) | a list of choices at a point |
openIconMenu(x, y, choices, icons, selected, onPick) | the same, with icons |
openDialog(title, content) | a modal box over a dimmed interface |
openSheet(title, content) | the same, dismissed by a click outside or Escape |
closePopup() popupOpen() | put it away, ask whether anything is showing |
.tip(s) | a tooltip, as a modifier rather than a call |
Nothing needs installing: the run loop draws this layer above the application's own overlay, every frame.
theme() | the one in force — everything reads it |
useTheme(t) | change it |
flipTheme() | swap dark and light |
darkTheme() lightTheme() | the two built in |
A Theme holds bg surface surfaceHi surfaceDown border
borderStrong · textPrimary textSecondary textMuted textOnAccent ·
accent accentHover accentDown accentSoft · success warning
danger shadow · radiusSm radiusMd radiusLg radiusPill · unit
hairline · fontSm fontBase fontLg fontTitle · control strip ·
dark.
And t.gap(n) for n units of the spacing grid, t.body() t.small()
t.heading() t.title() for the text styles.
rgb(r, g, b) rgba(r, g, b, a) gray(v) | channels, 0–255 |
web("#3b82f6") | three, six or eight hex digits, with or without the # |
redOf greenOf blueOf alphaOf | back out again |
withAlpha(c, a) fade(c, f) | change the opacity |
mix(a, b, t) lighten(c, f) darken(c, f) | between colours |
luma(c) contrasting(c) | how bright it reads; black or white on top of it |
over(dst, src, cov) | composite — the per-pixel operation |
An unreadable web string answers opaque magenta, which is easier to see on
screen than it is to miss in a log.
Rect(x, y, w, h) Pt(x, y) Size(w, h) Insets(l, t, r, b), and
all(v) axes(h, v).
right bottom centerX centerY center sizeOf isEmpty | reading one |
holds(r, x, y) | is the point inside? Left and top edges belong to it, right and bottom do not |
inset shrink grow offset | moving one |
intersect union overlaps centered | two of them |
cut(r, edge, amount) / cutRest(…) | take a strip off an edge, or keep the rest |
clampF clampI lerpF floorI ceilI roundI | arithmetic |
Dock(node) dockOf(ids) | an arrangement |
leaf(ids) | a group of tabs |
beside(a, b, f) above(a, b, f) | a split, a taking fraction f |
panelOf(id, title, body) | a dockable panel |
dock.add(panel) dock.panel(id) dock.holder(id) | registering and finding |
dock.reveal(id) | bring a panel to the front of its group |
dock.detach(id) | take it out; empty groups and their splits are pruned |
dock.dropInto(nid, zone, id) | put it back: zoneLeft zoneRight zoneTop zoneBottom zoneCentre |
dockView(dock) | the arrangement, as views |
dockOverlay(dock) | the highlight while a panel is being dragged — give it to app.overlay |
An Event has kind x y button key mods clicks dx dy text,
and e.cmd() (Command on macOS, Control elsewhere), e.shift(), e.alt(),
e.has(mod).
Kinds: evNone evClose evResize evMove evDown evUp evScroll
evKeyDown evKeyUp evText evFocus evBlur evExpose.
Named keys: keyLeft keyRight keyUp keyDown keyEnter keyTab
keyBackspace keyDelete keyEscape keyHome keyEnd keyPageUp
keyPageDown keySpace. Printable characters arrive as evText instead.