A form is fixed syntax that builds a step, a text or a piece of UI: s!(…), choice!{…},
t![…], and the UI forms such as column![…] and button!{…}. Each form desugars to ordinary
struct expressions, closures and calls of standard library items, which a .kis file may also
write by hand.
Forms are not macros. There are no macros in Kagari, and the set of forms is fixed by the
language. An unknown form name is a syntax error (E0001).
What steps mean for the story is in Steps; what the UI forms build is in
the STD Reference, std::ui. This chapter gives their exact
syntax and desugaring.
Where forms are allowed
- Forms are allowed only in
.kixfiles. A form in a.kisfile isE0001. - A form is an expression: a name,
!, then one of(,[or{.a != bis never a form. - Desugaring happens before name resolution, and the desugared code always names the standard
library items (
std::narrative::push_step,std::ui::button, …), even where a local item has the same name. - After desugaring, the ordinary rules apply: a step runs only in a scene (
E3002), UI functions arefncode (call rules), and so on.
General syntax
form = name "!" "(" entries ")" | name "!" "[" entries "]" | name "!" "{" entries "}" | "t" "!" "[" { text-item } "]"entries = [ entry { "," entry } [ "," ] ]entry = attribute | arm | expressionattribute = name "=" expressionarm = expression [ "if" expression ] "=>" expressiontext-item = string | ruby | effect | "{" expression "}"ruby = "[" string "]" "(" string { "|" string } ")"effect = "!" "[" ( string | ruby ) "]" "(" expression ")"The delimiter tells what a form accepts:
| Delimiter | Accepts | Forms |
|---|---|---|
name!( … ) | Attributes, then a fixed number of elements | s!, text!, image!, game_screen! |
name![ … ] | Elements only; no attributes | t!, column!, row!, layer! |
name!{ … } | Attributes, then elements | choice!, button!, slider!, toggle!, select! |
Every form has exactly one delimiter. These are syntax errors (E0001):
- the wrong delimiter (
`s!` is written `s!(…)`); - an attribute in a
[…]form, an attribute after an element, a duplicate attribute, an attribute the form does not know, a missing required attribute; - the wrong number of elements;
- an arm (
… => …) anywhere but inchoice!; - a comma between the items of
t![…], which are juxtaposed.
key = expression is an attribute only directly inside a form; anywhere else = is assignment.
Entries are separated by commas, and a trailing comma is allowed.
A brace form (choice!{…}, button!{…}, …) at the start of a statement is block-like: it needs no
; after it. s!(…) and the other forms need one, like any expression statement.
Summary of the forms
| Form | Attributes | Elements | Desugars to |
|---|---|---|---|
s!(…) | who, voice, render, history | at most one, a t![…] | push_step(Say { … }) |
choice!{…} | id (required), render | arms t![…] => Variant, t![…] if guard => Variant | push_step(Choice { … }) |
t![…] | — | text items, juxtaposed | concat([…]) of ruby, mono_ruby, effect, to_text calls |
column![…], row![…] | — | any number | ui::column([…]), ui::row([…]) |
layer![…] | — | any number | ui::layer([…]) |
text!(…) | style | exactly one | ui::text(TextProps { … }) |
image!(…) | fit | exactly one | ui::image(ImageProps { … }) |
game_screen!(…) | game (required) | none | ui::game_screen(GameScreenProps { … }) |
button!{…} | on_click, disabled, style | any number | ui::button(ButtonProps { … }) |
slider!{…} | value (required), on_change, step, disabled, style | none | ui::slider(SliderProps { … }) |
toggle!{…} | value (required), on_change, disabled, style | any number | ui::toggle(ToggleProps { … }) |
select!{…} | value (required), on_change, disabled, style | any number | ui::select(SelectProps { … }) |
s!(…)
s!(t!["It was the last day of summer."]);s!(who = "Azusa", render = azusa_smiles, t!["Did you forget again?"]);s!(render = cg_fireworks);s!(who = "Azusa", history = t!["(Azusa names the date)"], t!["Today is day " {DAY} "."]);| Attribute | Type | Meaning |
|---|---|---|
who | Text | The speaker name. Without it, the message is narration |
voice | VoiceAsset | The voice line played with the message |
render | Render | How the screen changes when the step is entered |
history | Text | The text the backlog records instead of the message |
All attributes are optional. The element, if any, must be written as a t![…] form (E0001
otherwise), and it becomes the field text. To show a Text held in a constant, interpolate it:
t![{GREETING}].
Each field is Some(value) when the attribute or element is present and None otherwise, and the
whole form is a call of push_step, so
s!(who = "Azusa", render = azusa_smiles, t!["Did you forget again?"]);means
push_step(Say { who: Some("Azusa"), voice: None, render: Some(azusa_smiles), text: Some("Did you forget again?"), history: None,});(t!["Did you forget again?"] is the string itself; see t![…].) The expression has type
().
choice!{…}
enum Confess { Yes, No }
let answer = choice!{ id = "confess", render = azusa_waits, t!["Tell her the truth"] => Confess::Yes, t!["Say nothing"] if !MET_MIO => Confess::No,};| Attribute | Type | Required | Rule |
|---|---|---|---|
id | string literal | yes | Missing or not a string literal: E0001. Used twice in one function: E3019 |
render | Render | no | As for s! |
Every element is an arm: t![…] => Variant or t![…] if guard => Variant.
- The text must be written as a
t![…]form; a plain string literal isE0001. - The guard is a
boolexpression, evaluated when the choice is entered. An arm whose guard is false is not offered. - The value must be a fieldless variant of an enum (
E3020). All arms use variants of one enum (E2001otherwise), and each variant at most once (E3020). Thechoice!expression has that enum type.
The form desugars to a Choice with one ChoiceArm per arm, in source order; an arm without a
guard has shown: true. The example above means
let answer = push_step(Choice { id: "confess", render: Some(azusa_waits), arms: [ ChoiceArm { text: "Tell her the truth", shown: true, value: Confess::Yes }, ChoiceArm { text: "Say nothing", shown: !MET_MIO, value: Confess::No }, ],});A choice that can be reached with every arm hidden is E5003. Guards, disabled arms and stable
choice ids are explained in Steps.
t![…]
t "の" ["漢字"]("かん"|"じ") )) "花。"]t![…] builds a Text. Its items are juxtaposed, without commas, and are concatenated exactly:
no whitespace is added or removed between them.
| Item | Meaning | Desugars to |
|---|---|---|
"…" | Plain text | The string literal |
["数多"]("あまた") | Ruby: one reading over the whole base (group ruby) | ruby("数多", "あまた") |
["漢字"]("かん"|"じ") | Ruby: one reading per character of the base (mono ruby) | mono_ruby("漢字", ["かん", "じ"]) |
 | The base shown with the text effect e | effect("赤い", e) |
](e) | A ruby with a text effect | effect(ruby("数多", "あまた"), e) |
{expr} | The value of expr as text | to_text(expr) |
The whole form is
""when it has no items;- the single item’s expression when it has one item, so
t!["Mio"]is"Mio"andt![{DAY}]isto_text(DAY); concat([…])of the items otherwise.
t)]means
concat(["Day ", to_text(DAY), ": ", effect("the festival", emphasis())])ruby, mono_ruby, effect, concat and to_text are const fn builtins of
std::narrative::text.
Strings
A string item is a string literal with the escapes \n (line break), \r, \t, \\, \", \0
and \u{…}. A line break typed inside the quotes is part of the text as well. A string literal
has type Text anywhere, so an attribute such as who = "Mio" needs no t!.
Ruby
[base](readings) puts ruby over base, which is one string literal.
- One reading: group ruby over the whole base.
- Several readings separated by
|: mono ruby, one reading per character of the base. The number of readings must equal the number of characters (E3021).
Text effects
 shows base, a string literal or a ruby item, with a text effect. effect
is an expression of type TextEffect and must be a constant expression
(E3022), so a message is still a constant of its step. Effects combine with .and(…); fields
set on the right win. Interpolation is not allowed inside the base.
t) "."]t.and(color_text(rgb(200, 0, 0)))) "."]The effects (color_text, bold, italic, size, font, outline, shadow, emphasis,
dots, speed, speed_scale, reveal_instant, pause, click_wait, mark) are listed in the
STD Reference, std::narrative::text.
Pauses and marks
A cue is an effect that acts on the reveal of the text rather than on its look. Its base is usually the empty string:
s!(who = "Mio", render = mio_startled, t).and(mark(1))) "—what?"]);s!(t) "\nThen go on."]);| Cue | Effect |
|---|---|
pause(d) | The reveal stops for d before the base |
click_wait() | The reveal stops until a click; the click continues the text in the same step |
mark(n) | Names the moment the reveal reaches the base, so a render can schedule an animation there |
A mark number used twice in one text is E4010. The exact reveal, pause and mark rules are in the
STD Reference, std::narrative::text.
Interpolation
{expr} inserts the value of expr as text. The type of expr must implement the standard trait
ToText (E2026 otherwise): Text, String, Int<lo, hi>, the Rust integers, Rat and
Decimal. In a pushdown fn, interpolating a finite runtime value such as a story variable is a
call of the const fn to_text with a finite argument, which the compiler
tabulates.
UI forms
column![ text!(t!["Text speed: " {speed()}]), slider!{value = speed(), on_change = |v| set_speed(v)}, button!{on_click = || nav::back(), t!["Back"]},]The UI forms build Element values of std::ui in fn code.
They desugar by these rules:
- Elements. Each element
ebecomes the closure|| e. Incolumn!,row!,button!andtoggle!, an element that is written as at![…]form becomes|| ui::label(t![…]). - Reactive attributes. Every attribute whose name does not start with
on_becomes a closure:key: || exprwhen the attribute is required,key: Some(|| expr)when it is optional. The UI re-runs the closure when a signal it reads changes. - Handler attributes. An
on_*attribute is passed as written:key: Some(expr). - Absent optional attributes become
None. column!,row!andlayer!pass the array of element closures directly. The other forms pass a props struct whose fields are the attributes, in the order declared instd::ui, plus a field for the elements.
| Form | Target | Attributes and their types | Elements |
|---|---|---|---|
column![…], row![…] | ui::column(children), ui::row(children) | none | Element; any number |
layer![…] | ui::layer(children) | none | Placed; any number; t![…] is not wrapped |
text!(…) | ui::text(TextProps { … }) | style: TextStyle | exactly one Text → content |
image!(…) | ui::image(ImageProps { … }) | fit: Fit | exactly one ImageAsset → source |
game_screen!(…) | ui::game_screen(GameScreenProps { … }) | game: Narrative, required | none |
button!{…} | ui::button(ButtonProps { … }) | on_click: Fn(); disabled: bool; style: ButtonStyle | Elements → children |
slider!{…} | ui::slider(SliderProps { … }) | value: Int<LO, HI>, required; on_change: Fn(Int<LO, HI>); step: u32; disabled: bool; style: SliderStyle | none |
toggle!{…} | ui::toggle(ToggleProps { … }) | value: bool, required; on_change: Fn(bool); disabled: bool; style: ButtonStyle | Elements → children |
select!{…} | ui::select(SelectProps { … }) | value: T, required; on_change: Fn(T); disabled: bool; style: ButtonStyle | ui::option(value, label) items → options; t![…] is not wrapped |
So
button!{on_click = || nav::back(), t!["Back"]}means
std::ui::button(std::ui::ButtonProps { on_click: Some(|| nav::back()), disabled: None, style: None, children: [|| std::ui::label("Back")],})and
slider!{value = speed(), on_change = |v| set_speed(v)}means
std::ui::slider(std::ui::SliderProps { value: || speed(), on_change: Some(|v| set_speed(v)), step: None, disabled: None, style: None,})A slider’s range is the range of its value’s Int type; there are no min or max attributes.
Writing forms by hand
A .kis file, or a .kix file that prefers it, writes the desugared code directly. Every target
is an ordinary public item: push_step, Say, Choice and ChoiceArm are in the prelude, the
text builtins are in std::narrative::text, and the UI functions and props structs are in
std::ui. Hand-written code gets the same checks as the forms: a hand-written Choice must use a
string literal id (E3019) and an array literal of ChoiceArm { … } records with fieldless
variants (E3020).
Last updated Oct 29, 2026