Expand description
§denise-forms
Loads a DeniseUI form file into a widget tree at runtime.
[dependencies]
denise-forms = "0.23"A form in Denise is ordinarily Rust: a Ui<M>, a tree of ui.add(parent, widget, rect) calls, and a match over the messages that come back. This crate is the
other way to say the same thing — a .dform file a visual designer writes, a
person edits, git diff reads, and this loads.
The file format is documented in full: KDL, one form per file, one rectangle per node.
§Building a form
use denise_forms::{Form, Handler, Payload};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Message {
Greet,
}
let form = Form::parse(r#"
form "Hello" version=1 kind=screen width=460 height=260 {
label "What is your name?" x=20 y=20 w=388 h=20
text-input name=who x=20 y=44 w=388 h=34
button "Greet" x=20 y=90 w=110 h=34 role=primary on-press=greet
}
"#)?;
let mut ui: Ui<Message> = Ui::new(form.size(), form.theme());
let root = ui.root();
let built = form.build(&mut ui, root, &mut |name: &str, _: Payload| match name {
"greet" => Some(Handler::Plain(Message::Greet)),
_ => None,
})?;
// Nodes the file named can be found again; the rest need no name.
let field = built.node("who").expect("the field");§Messages are names, and the application owns them
A widget holds a value of your message type and this crate has never seen your type, so the file holds a name and you map it. That closure is the whole bridge, and an unknown name is an error at load with the name in it — not a button that silently does nothing, which is the failure every string-keyed UI format has and the reason people distrust them.
The Handler you return is not always a plain message, because the widgets are
not all the same shape. A Button holds an M; a Checkbox holds a
fn(bool) -> M; a List holds a fn(usize) -> M; a Slider holds a
fn(f32) -> M. Those are function pointers, so no closure of this crate’s can
stand in for one — which is exactly what an enum’s tuple variant already is:
#[derive(Clone, Copy)]
enum Message {
Save,
Notify(bool),
Pick(usize),
}
let resolve = |name: &str, _: Payload| match name {
"save" => Some(Handler::Plain(Message::Save)),
// `Message::Notify` *is* a `fn(bool) -> Message`.
"set-notify" => Some(Handler::Bool(Message::Notify)),
"pick" => Some(Handler::Index(Message::Pick)),
_ => None,
};The Payload you are handed says which shape the widget wants, so a resolver
that serves several can answer without guessing. Returning the wrong one is an
error naming the message and what was needed.
§Pictures
src on an image, an avatar or a carousel’s pictures is a path relative
to the form file, never to the working directory — so a form and its pictures
move together and a kiosk’s current directory is not part of the contract.
This crate decodes nothing and does not depend on denise-image: implement
Wiring::asset and hand back pixels. That is also how a board with its pictures
compiled in serves them from a table instead of touching a disk.
§Errors are the product
A form is something a person typed, so every failure carries a line, a column, and — where there is a finite set of right answers — the whole set:
14:9: `button` has no property `colour`; it accepts corner, no-focus, on-press,
radius, repeat-delay, repeat-interval, role, size, text, watch-hold§A form you did not write
Form::parse is bounded in shape and not in time, and it cannot be: kdl
6.7.1 parses some malformed documents in exponential time
(kdl-org/kdl-rs#177), a hundred
and thirty bytes for seventy-eight seconds. A byte scan refuses MAX_SOURCE,
MAX_DEPTH, MAX_COMMENTED_DEPTH and unbalanced braces before the file reaches
the parser at all, and that covers every slow input the fuzzer has found — but
agreeing with kdl about where a string ends means being kdl’s lexer, and
anything that slips past costs whatever kdl costs.
So Form::parse_within is the same parse with a deadline, and it is what to
call for a form that was opened, pasted, downloaded or handed over on a stick:
let source = std::fs::read_to_string(path)?;
let form = Form::parse_within(&source, PATIENCE)?;PATIENCE is one second — three hundred times the 3 ms the reference form, every
node kind this toolkit has, takes to parse — and the limit is an argument
because a four-megabyte form and a slow panel both move it.
What a missed deadline does is abandon the parse, not stop it: a thread
cannot be cancelled and kdl has no point at which to ask. The call returns
Reason::TooSlow and the worker keeps going, so MAX_ABANDONED (4) bounds
what that costs the process — the fifth such parse is refused before it starts,
rather than taking the last core with it. A form baked in with include_str!
needs none of this: it was read at build time from a file in the repository.
§The command-line tool
cargo install denise-forms --features cliBehind a feature, and not a default one: the library’s job on a panel is to turn a file into widgets, and a kiosk linking it should not also link three image decoders because a command-line tool needs them to draw a picture into a PPM.
denise-forms check settings.dform # exit 1, with positions
denise-forms check $(git ls-files '*.dform')
denise-forms render settings.dform out.ppm # --theme light, --font path.ttf
denise-forms render --scale 2 settings.dform out.ppm
denise-forms render --size 1920x1080 settings.dform panel.ppm
denise-forms fmt settings.dform # --check to report and write nothingcheck parses the file and builds it, so it catches everything a panel
would, and prints file:line:column: message. It also lints geometry — a node
outside its parent, a pair of siblings on top of each other — as warnings, since
with no layout engine nothing else ever will. --no-lint turns that off,
--quiet says nothing unless something is wrong.
fmt lays the indentation out again and changes nothing else: one step per level
of nesting, trailing whitespace gone, and only the whitespace at the two ends
of a line is ever touched. Comments keep their text and their place, strings
keep their quoting, properties keep their order, blank lines stay blank lines,
and columns lined up by hand inside a line stay lined up. The step is the file’s
own — whatever the first node inside form uses — so a two-space file stays a
two-space file. --check writes nothing and exits non-zero if anything would
change.
It is not a canonical formatter on purpose. kdl’s own deletes a comment written
at the end of a node’s line, which is not a thing to ship into a format whose
first promise is that comments survive; re-indenting is the part hand-editing
actually breaks, and the part that can be done without touching a byte anybody
wrote. See tidy, which is the same thing from Rust.
render draws one frame with no display attached. Deterministic: without
--font it uses the built-in bitmap font rather than whatever the machine has
installed, so two renders of one file are the same bytes and a snapshot is worth
committing. --font draws the whole form in that face instead, and needs the
truetype feature.
--scale and --size ask different questions. --scale 2 is the same panel at
twice the density: one factor, and the picture grows with the form. --size 1920x1080 is this actual panel: the surface is what you asked for, and the
file’s own scaling= decides what happens inside it — its own size in the middle,
scaled to fit, or stretched. That last one cannot be reviewed any other way.
§Scaling one
A form declares whether it may be drawn at a size other than the one it was designed at, because the form is the thing that knows: a dial against a 1:1 photographic background, or a panel whose touch targets are already the smallest a gloved finger can hit, should be centred rather than stretched however big the display is.
form "Dashboard" version=1 kind=screen width=1024 height=600 scaling=proportionalnone (the default, and what every form written before the property existed
already did), proportional, or stretch. Loading it is three lines, and all
three matter:
let fit = form.fit(surface);
// The theme too, or every widget is the old size inside a new rectangle.
let mut ui: Ui<Void> = Ui::new(surface, form.theme().scaled(fit.uniform()));
let root = ui.root();
let stage = ui.add(root, Panel::filled(form.background()), fit.rect).unwrap();
let built = form.build_fitted(&mut ui, stage, fit, &mut wiring)?;Every rectangle scales by its edges, so two panels designed to touch still touch at 0.75×. Every number a widget declares to be a length in logical pixels — a text size, a row height, a border width — scales with it; a duration, a count and a selected index do not. The widget is what says which of its own numbers is which, so there is no table of widgets here.
The full argument, including why text scaling is a DPI answer rather than a “bigger screen” answer, is in docs/forms.md.
§Or let the compiler check the names
[build-dependencies]
denise-forms = { version = "0.23", features = ["codegen"] }// build.rs
fn main() {
denise_forms::codegen::to_out_dir("forms/hello.dform").unwrap();
}Out of it comes a struct Hello with a NodeId field per named node and an
enum HelloMessage with a variant per message the form emits, carrying that
widget’s payload. Rename a node and the application stops compiling; add a
message and every match stops being exhaustive.
A build script rather than a proc macro, deliberately: the output is a file you
can open, cargo doc sees it, and it needs no second crate. The generated
build calls this same engine, so a form loaded at runtime and the same form
generated behave identically.
Only the build script links the generator — the feature is off by default, and a panel links the engine and nothing else.
§Editing one, byte for byte
The other half of the crate, and the one the designer
is built on. A Form holds the document, not a struct taken from it, so an
edit changes what it names and nothing else:
// A comment, and columns somebody lined up by hand.
let source = "\
// The panel everything sits on.
form \"F\" version=1 width=320 height=240 {
label \"One\" x=8 y=8 w=80 h=20
label \"Two\" x=8 y=32 w=80 h=20
}
";
let mut form = Form::parse(source)?;
let undo = form.apply(Edit::number(&[1], "y", Some(40)))?;
assert_eq!(form.text(), source.replace("x=8 y=32", "x=8 y=40"));
// Every edit hands back the edit that reverses it, so undo is applying that —
// there is no snapshot of anything, anywhere.
form.apply(undo)?;
assert_eq!(form.text(), source);The inverse carries the text that was there and not the value, which is why
this is byte-exact rather than merely correct: 1_000, 0x10 and 70.0 are
values a typed inverse would carry, and none of them would be written back the
way they were written down.
That this holds for files people actually write, rather than only for the ones in
the doc comments, is what tests/awkward/
is for: a corpus with comments in every position KDL allows one, columns lined up
by hand, a property written three times, strings with escapes and emoji in them,
a panel with no braces and a file with no trailing newline. The tests walk the
directory, so defending a new way of writing a form by hand is adding a file.
A corpus only covers what somebody thought of, so Form::parse does not take
the round trip on trust: it writes the document back out and compares it to the
source, and a file it cannot reproduce is refused (Reason::NotPreserved)
rather than accepted and corrupted on the first save. That check is also what
the fuzz target parse_form asserts on, which is how the two shapes kdl was
quietly eating — trailing whitespace after a closing brace, and a comment
written on the brace’s line — were found and put back. See
fuzz/README.md.
Edit::Move reparents a node — its children, its indentation and the comment
above it all going with it — and Edit::Many makes a run of edits one step, so a
gesture that changes four numbers is one thing to undo. The empty path is the
form node itself, so its size, kind and theme are edited through the same door.
Form::node_text hands one node back as source and fragment reads source
back into nodes, which is how copy and paste carry .dform text on the system
clipboard: paste into a text editor and you have the source, paste from one and
you have the nodes.
§What this crate does not do
It does not open anything. Form::kind reports that a file is a dialog;
whether that becomes Ui::push_scene on a panel or a modal window on a desktop is
the application’s decision, and it differs by machine.
It does not lay anything out. Nodes are rectangles, with the toolkit’s own anchors and docking over them. There is no solver here and none below.
It does not know what a clipboard or a window is. Editing a form is text in,
text out; whose clipboard that text came from is the tool’s business, which is
why arboard is a dependency of the designer and never of this crate.
Modules§
- codegen
- A form file, as Rust the compiler checks.
Structs§
- At
- Where in the source something is, counted from one as an editor counts.
- Built
- What a form built, so an application can find what it made.
- Error
- A failure, and where in the file it is.
- Form
- A parsed form file.
- Page
- One
tab’s page: the container its subtree was built into. - Picture
- Pixels for a picture a form named, as
Wiring::assethands them back. - Placed
- One node the form put in the tree, and where in the file it came from.
- Placement
- Where a form goes on a surface, and by how much it is multiplied to get there.
- Written
- One node of a form file, as the file writes it.
Enums§
- Edit
- One reversible change to a form.
- Form
Kind - What a form is for.
- Handler
- A message a form named, in the shape the widget holding it needs.
- Literal
- A property’s value, as a form file writes it.
- Payload
- What a widget hands its message constructor when it fires.
- Reason
- Everything that can be wrong with a form file.
- Scaling
- Whether a form may be drawn at a size other than the one it was designed at.
Constants§
- DESIGN
- The block a designer’s placeholder content lives in.
- FORM_
PROPERTIES - The properties the
formnode itself carries, whatever kind it is. - MAX_
ABANDONED - How many parses may be running past their deadline before another is refused outright.
- MAX_
COMMENTED_ DEPTH - How deeply a form may nest a commented-out children block — a
{ … }belonging to a node a/-has commented out. - MAX_
DEPTH - How deep a form may nest.
- MAX_
SOURCE - How large a form file may be.
- NODE_
PROPERTIES - The properties the tree owns rather than the widget.
- PATIENCE
- How long to give a form before abandoning it, when there is no better number to hand.
- THEMES
- The themes a form may name.
- VERSION
- The schema version this crate reads.
Traits§
- Wiring
- What an application supplies a form that this crate cannot: its own message type, and its own pictures.
Functions§
- after_
removing - A path as it stands once
removedhas been taken out. - default_
size - How big a new widget of this kind should start out.
- form_
property - Whether the
formnode may carry this property, given its kind. Seekind_properties. - fragment
- The top-level nodes of a fragment of form source, each as its own text.
- kind_
properties - The properties a form of this kind carries and no other kind does.
- node_
property - The tree-owned property of this name, if there is one.
- owns_
children - Whether a widget of this kind can hold nodes of their own.
- seed
- The smallest node of this kind that a form can actually hold, as file text.
- seed_
form - A whole form file with nothing in it yet, writing only what is not a default.
- tidy
- Lays a form’s indentation out again, and changes nothing else.