Kagari
Type to search documentation.

Syntax

Forms

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 .kix files. A form in a .kis file is E0001.
  • A form is an expression: a name, !, then one of (, [ or {. a != b is 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 are fn code (call rules), and so on.

General syntax

text
form = name "!" "(" entries ")"
| name "!" "[" entries "]"
| name "!" "{" entries "}"
| "t" "!" "[" { text-item } "]"
entries = [ entry { "," entry } [ "," ] ]
entry = attribute | arm | expression
attribute = name "=" expression
arm = expression [ "if" expression ] "=>" expression
text-item = string | ruby | effect | "{" expression "}"
ruby = "[" string "]" "(" string { "|" string } ")"
effect = "!" "[" ( string | ruby ) "]" "(" expression ")"

The delimiter tells what a form accepts:

DelimiterAcceptsForms
name!( … )Attributes, then a fixed number of elementss!, text!, image!, game_screen!
name![ … ]Elements only; no attributest!, column!, row!, layer!
name!{ … }Attributes, then elementschoice!, 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 in choice!;
  • 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

FormAttributesElementsDesugars to
s!(…)who, voice, render, historyat most one, a t![…]push_step(Say { … })
choice!{…}id (required), renderarms t![…] => Variant, t![…] if guard => Variantpush_step(Choice { … })
t![…]—text items, juxtaposedconcat([…]) of ruby, mono_ruby, effect, to_text calls
column![…], row![…]—any numberui::column([…]), ui::row([…])
layer![…]—any numberui::layer([…])
text!(…)styleexactly oneui::text(TextProps { … })
image!(…)fitexactly oneui::image(ImageProps { … })
game_screen!(…)game (required)noneui::game_screen(GameScreenProps { … })
button!{…}on_click, disabled, styleany numberui::button(ButtonProps { … })
slider!{…}value (required), on_change, step, disabled, stylenoneui::slider(SliderProps { … })
toggle!{…}value (required), on_change, disabled, styleany numberui::toggle(ToggleProps { … })
select!{…}value (required), on_change, disabled, styleany numberui::select(SelectProps { … })

s!(…)

kix
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} "."]);
AttributeTypeMeaning
whoTextThe speaker name. Without it, the message is narration
voiceVoiceAssetThe voice line played with the message
renderRenderHow the screen changes when the step is entered
historyTextThe 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

kix
s!(who = "Azusa", render = azusa_smiles, t!["Did you forget again?"]);

means

kis
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!{…}

kix
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,
};
AttributeTypeRequiredRule
idstring literalyesMissing or not a string literal: E0001. Used twice in one function: E3019
renderRendernoAs 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 is E0001.
  • The guard is a bool expression, 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 (E2001 otherwise), and each variant at most once (E3020). The choice! 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

kis
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![…]

kix
t!["これは" ["数多"]("あまた") "の" ["漢字"]("かん"|"じ") !["赤い"](color_text(rgb(200, 0, 0))) "花。"]

t![…] builds a Text. Its items are juxtaposed, without commas, and are concatenated exactly: no whitespace is added or removed between them.

ItemMeaningDesugars to
"…"Plain textThe string literal
["数多"]("あまた")Ruby: one reading over the whole base (group ruby)ruby("数多", "あまた")
["漢字"]("かん"|"じ")Ruby: one reading per character of the base (mono ruby)mono_ruby("漢字", ["かん", "じ"])
!["赤い"](e)The base shown with the text effect eeffect("赤い", e)
![["数多"]("あまた")](e)A ruby with a text effecteffect(ruby("数多", "あまた"), e)
{expr}The value of expr as textto_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" and t![{DAY}] is to_text(DAY);
  • concat([…]) of the items otherwise.
kix
t!["Day " {DAY} ": " !["the festival"](emphasis())]

means

kis
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

![base](effect) 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.

kix
t!["Tell me about " !["you"](emphasis()) "."]
t!["It was " !["here"](dots().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:

kix
s!(who = "Mio", render = mio_startled, t!["Wait" ![""](pause(ms(500)).and(mark(1))) "—what?"]);
s!(t!["Read slowly." ![""](click_wait()) "\nThen go on."]);
CueEffect
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

kix
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 e becomes the closure || e. In column!, row!, button! and toggle!, an element that is written as a t![…] form becomes || ui::label(t![…]).
  • Reactive attributes. Every attribute whose name does not start with on_ becomes a closure: key: || expr when 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! and layer! pass the array of element closures directly. The other forms pass a props struct whose fields are the attributes, in the order declared in std::ui, plus a field for the elements.
FormTargetAttributes and their typesElements
column![…], row![…]ui::column(children), ui::row(children)noneElement; any number
layer![…]ui::layer(children)nonePlaced; any number; t![…] is not wrapped
text!(…)ui::text(TextProps { … })style: TextStyleexactly one Text → content
image!(…)ui::image(ImageProps { … })fit: Fitexactly one ImageAsset → source
game_screen!(…)ui::game_screen(GameScreenProps { … })game: Narrative, requirednone
button!{…}ui::button(ButtonProps { … })on_click: Fn(); disabled: bool; style: ButtonStyleElements → children
slider!{…}ui::slider(SliderProps { … })value: Int<LO, HI>, required; on_change: Fn(Int<LO, HI>); step: u32; disabled: bool; style: SliderStylenone
toggle!{…}ui::toggle(ToggleProps { … })value: bool, required; on_change: Fn(bool); disabled: bool; style: ButtonStyleElements → children
select!{…}ui::select(SelectProps { … })value: T, required; on_change: Fn(T); disabled: bool; style: ButtonStyleui::option(value, label) items → options; t![…] is not wrapped

So

kix
button!{on_click = || nav::back(), t!["Back"]}

means

kis
std::ui::button(std::ui::ButtonProps {
on_click: Some(|| nav::back()),
disabled: None,
style: None,
children: [|| std::ui::label("Back")],
})

and

kix
slider!{value = speed(), on_change = |v| set_speed(v)}

means

kis
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