Folio Docs
Fogspire/Folio
git clone https://github.com/Fogspire/Folio.git Plugins/Folio

Rich text, terms and the typewriter

A line of dialogue has emphasis in the middle, names that have to read the same everywhere, pacing, and it has to be translatable as one string, so a translator can move the emphasis onto whichever word carries it in their language. <text> is that string.

The line drawn: "must" in bold red, "Mountain Temple" in the place colour

RML
<text class="line" data-loc="line_1">You <angry>must</> go to the {tag:Place.Mountain.Temple},
	I{delay:0.2}...{/} think?</text>
RCSS
.line  { reveal: typewriter; reveal-rate: 45; reveal-punctuation: 0.18; }
.angry { color: #e06c75; font-weight: bold; }
.term  { color: #61afef; }

"must" is bold and red. "Mountain Temple" comes out of a table shared by the whole game and carries that table's colour. The ellipsis types slowly and the words after it go back to normal speed.

What is inside a <text> is not RML. It is read raw and parsed by Folio, so <angry> is a style rather than an element and {tag:} is a term rather than a data binding. A translation is a string, and cannot introduce elements into a document: anything a translator sends back is markup Folio reads, never markup RmlUi parses.

The grammar

<style>words</>Applies the class style to what it encloses. </style> also works where naming it reads better.
{tag:Some.Thing}Substitutes a name from UI/terms. Does not enclose anything.
{delay:0.2}slow{/}Every character inside waits 0.2s. The one after it goes back to the rate.
{pause:0.5}Waits once, before the next character.
{br}A line break.
\< \> \{ \} \\The literal character.

Styles nest, and the classes accumulate: <angry><whisper>x</></> puts class="angry whisper" on the run. </> and {/} each close the nearest of their own kind.

A style tag becomes a class of the same name. Folio has no fixed vocabulary: <angry> means whatever .angry says. The default theme ships .angry, .shout, .soft, .aside and .term as a starting point; rename, add or delete. A tag your writers use needs a rule somewhere, or the word it marks silently renders as ordinary text.

Whitespace collapses, so a long line can be indented.

Terms

A term is a name written once: a place, a character, an item, a quest. Every document and translation refers to it by tag, so the name is translated once.

# UI/terms/places.terms
@class term

Place.Mountain.Temple = Mountain Temple
Place.Village.Kurogane = Kurogane Village

One file per group of names, all loaded at startup, all merged. @class names the style class every entry in that file carries, so a place reads differently from the sentence around it.

Tags are strings that look like gameplay tags, and nothing checks them against a gameplay tag registry: the table is UI data. A term defined twice is an error naming both files.

Tag:form = ... adds a named form of an entry, reached as {tag:Place.Mountain.Temple:gen}. English uses none. It is for languages that inflect a name.

These are files rather than engine data because the preview tool has no engine.

  • folio.Terms prints every loaded term and what it resolves to.
  • folio.Terms.Reload reads the tables again and rebuilds every open document. Saving a .terms file does the same with hot reload on.

The typewriter

reveal: typewriter types the line out one character at a time, each fading in.

All of it is RCSS and inherited, so a game sets how its dialogue reads once:

revealnone | typewriterOff by default; a <text> is then styled text.
reveal-ratenumberCharacters per second. 45 is conversational, 90 is brisk.
reveal-fadenumberSeconds each character takes to fade in.
reveal-riselengthHow far each character lifts as it appears. 0 for none.
reveal-punctuationnumberExtra seconds after . ! ?, half that after , ; :.
reveal-soundstringNamed and reported, never played. See below.
reveal-startauto | manualmanual waits for the game to call Play().

reveal-punctuation is the one to tune: an even stream of characters reads flat, and a beat after a full stop is most of what makes it sound like someone talking. {delay:} and {pause:} are for moments the writer wants specifically.

The element carries its own state

RCSS
.line.is-revealing ~ .continue { opacity: 0; }
.line.is-revealed  ~ .continue { opacity: 1; }

A continue prompt that appears when the line has finished is a stylesheet rule. revealcomplete is dispatched as well, for code.

Driving it from the game

A line the game owns (dialogue from a script asset, a quest description, a log entry) does not live in the document:

C++
Document->SetElementMarkup(TEXT("line"), Line.ToString());

That replaces the <text>'s markup and starts it revealing. The string is game text, translated through whatever asset it came from, so the element carries data-noloc rather than a key.

OnTextRevealed reports the id of a line that has finished, whether it ran to the end or was skipped. SkipElementReveal, IsElementRevealing and GetElementRevealDuration are for the input and the auto-advance.

Author the document with a line in it anyway, marked data-noloc: the game overwrites it, and the screen can be previewed.

Skipping

To complete a line at once:

C++
if (Folio::Text::FElementRichText* Text = Folio::Text::AsRichText(
		Canvas->GetDocument()->GetElementById("line")))
{
	Text->Skip();
}

Play() restarts it, IsRevealing() and IsComplete() say where it is, and GetDuration() is how long it takes from the start, for an auto-advance timer.

folio.Text.Skip does the same to every revealing line.

The player's speed

Two settings, for an options menu:

C++
UFolioAccessibilityLibrary::SetTextSpeed(2.0f);      // 1 is as authored
UFolioAccessibilityLibrary::SetInstantText(true);    // every line arrives whole

The multiplier divides the whole schedule: reveal-rate, an authored {delay:0.2} and the punctuation beats all move together. A line that pauses before a name still pauses, proportionally.

Instant text skips rather than never starting, so revealcomplete still fires and an auto-advance timer waiting on it still runs.

Neither is part of reduced motion: somebody can need menus still and dialogue paced, or instant text and every transition intact. See presentation settings.

uipreview --text-speed 4 and --instant-text preview both.

Sound

reveal-sound: "dialogue-blip" names a sound and plays nothing, in keeping with the rest of UI sounds. The game hears about it:

C++
void UMyAudioSubsystem::Initialize(FSubsystemCollectionBase&)
{
	FFolioModule::Get().OnTextRevealSound().AddUObject(this, &UMyAudioSubsystem::PlayNamedSound);
}

At most once a frame, however fast the text is typing.

How it is drawn, and why that matters

The runs become real elements (<angry>x</> is a <span class="angry">), so wrapping, alignment, line height, nesting and theming are RmlUi's, as for any other text. The contrast check sees them too.

While a line is revealing, those spans are visibility: hidden and Folio draws the glyphs itself, reading back the lines RmlUi laid out and painting each glyph with its own alpha. So:

  • The text never moves. Every character is laid out from the first frame, so the line wraps once, before anything is visible.
  • Shaping is intact. Each run is shaped whole, so ligatures, kerning and mark attachment are what they would be without a reveal.
  • It costs a mesh per frame while it types, and nothing once it stops. On the last character the spans are shown again and RmlUi draws them from geometry it generates once.

A <text> with reveal: none never takes that path.

Translating it

The markup is part of the string, and a translator sees it, as with Unreal's own rich text. Three things make it safe:

  1. gather_rml.py writes a translator brief beside the generated header: what the tags mean, what each {tag:} prints in English, and which strings carry which. Unreal's PO export has no room for a note against a string, so it travels beside the PO.

  2. The gather fails on bad markup. An unclosed tag, an unknown directive or a term with no entry is an error with a file and a line, before anything ships.

  3. What comes back is checked:

    Shell
    ./scripts/loc.sh --check-po Folio.po

    Every translation is parsed. A retyped tag, an invented style or an unclosed one is an error; a translator dropping a term is a warning, because writing a name out in full is a legitimate call when grammar demands it.

Terms are gathered into their own namespace, Folio.Terms, keyed by tag. A name is translated once there and every line that mentions it follows.

--culture pseudo inflates and accents the words and leaves the tags exactly as they were. See Localisation.

Limits

  • One data-loc covers the whole line, so a <text> cannot contain an <img>, a binding or any other element. It is prose.
  • {{ }} data expressions do not work inside it. The braces belong to the markup. A line that has to name the player is a job for a term or for SetMarkup from game code.
  • text-transform and the reveal disagree. The reveal walks the laid-out characters against the ones it parsed; a transform that changes their length makes it fall back to revealing in order, which is usually but not always right.
  • Right-to-left text reveals left to right. Bidi is a known gap in the text engine generally.

See also