Skip to main content

Crate denise_forms

Crate denise_forms 

Source
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 cli

Behind 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 nothing

check 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=proportional

none (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::asset hands 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.
FormKind
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 form node 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 removed has been taken out.
default_size
How big a new widget of this kind should start out.
form_property
Whether the form node may carry this property, given its kind. See kind_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.