Skip to content

Data Binding

curveo edited this page May 17, 2026 · 5 revisions

Data Binding

TesseraUI supports dynamic content through a TesseraModel object passed at render time. Templates can read model values with {{ }} expressions, loop with v-for, and show/hide elements with v-if and v-show.


TesseraModel

Build a model from a flat key-value map:

TesseraModel model = TesseraModel.of(Map.of(
    "player.name",   player.getName().getString(),
    "player.level",  String.valueOf(playerLevel),
    "player.health", String.valueOf((int) player.getHealth()),
    "items.count",   String.valueOf(items.size())
));

All values are strings. Numeric comparisons in expressions work on the string representation.

Use TesseraModel.EMPTY when your template has no dynamic content.


Interpolation {{ }}

Reference model values directly:

<label>Hello, {{ player.name }}!</label>
<label>Level: {{ player.level }}</label>

Concatenation

<label>{{ player.name }} — Level {{ player.level }}</label>

Ternary expression

<label>{{ items.count == 0 ? "Empty" : items.count + " items" }}</label>

Supported operators: ==, !=, >, <, >=, <=.

String literals in ternary conditions must also be quoted:

<!-- ✓ Correct -->
<label>{{ player.rank == 'admin' ? "Administrator" : "Player" }}</label>

<!-- ✗ Wrong — looks up model key "admin", not the string -->
<label>{{ player.rank == admin ? "Administrator" : "Player" }}</label>

Translation in expression

<label>{{ items.count == 0 ? t:ui.mymod.empty : items.count + " " + t:ui.mymod.items }}</label>

Use : (with spaces) as the ternary separator when a t: key is involved.


Loops — v-for

Repeat a block for each item in a list.

Model side

Pass the count and per-item fields:

TesseraModel model = TesseraModel.of(Map.of(
    "items",             String.valueOf(items.size()),   // count
    "item.name.0",       items.get(0).getName(),
    "item.rarity.0",     items.get(0).getRarity(),
    "item.name.1",       items.get(1).getName(),
    "item.rarity.1",     items.get(1).getRarity()
));

Template side

<col v-for="item in items">
  <label>{{ item.name }}</label>
  <badge>{{ item.rarity }}</badge>
</col>

item is the loop variable. Inside the template, {{ item.name }} resolves to item.name.<index> from the model.

The same rule applies inside <virtual-list v-for="item in items">: row data must still be keyed with the loop variable prefix (item.name.0, item.action.0, ...), and row templates should use the loop variable prefix ({{ item.name }}, onclick="{{ item.action }}").

For compatibility with early 1.0 templates, <virtual-list> also accepts bare row keys such as {{ name }}. Prefer the prefixed form because it matches regular v-for and avoids ambiguity in nested templates.

Inside <virtual-list> rows, bindings that cannot be resolved as row data fall back to the parent model. This lets global values such as {{ s.items }} or {{ screen.title }} be shared without copying them onto every row.

Nested access

<col v-for="slot in hotbar">
  <label>{{ slot.name }}</label>
  <label>× {{ slot.count }}</label>
</col>

Conditional rendering — v-if

Completely removes the element and its layout space when the expression is falsy:

<row v-if="player.isAdmin">
  <button onclick="openAdmin">Admin panel</button>
</row>

The element is not rendered and takes no space in the layout when the condition is false.

Expressions can be written bare or wrapped in {{ }} — both forms are equivalent:

<!-- Bare expression (recommended) -->
<label v-if="items.count > 0">{{ items.count }} items available</label>

<!-- Wrapped — also works -->
<label v-if="{{ items.count > 0 }}">{{ items.count }} items available</label>

Supported comparisons

<!-- Numeric: both sides resolved from model or as literals -->
<badge v-if="player.level >= 10">Expert</badge>

<!-- String equality: quote the literal with single or double quotes -->
<badge v-if="player.rank == 'admin'">Admin</badge>
<badge v-if="player.rank != 'guest'">Member</badge>

<!-- Two model keys compared -->
<label v-if="score == highScore">New record!</label>

<!-- Truthy check (true / 1 / yes / on) -->
<row v-if="player.isOnline">● Online</row>

Note: Always quote string literals with '...' or "...". An unquoted value like player.rank == admin looks up the key admin in the model, not the string "admin". If the key is absent, it is treated as an empty string.

Missing keys: A key that is not in the model resolves to "" (empty string), not 0. So missing == 0 is false, not true.


Conditional visibility — v-show

Keeps the element in the layout (space is preserved) but hides it:

<label v-show="player.isOnline">● Online</label>

<!-- Same expression syntax as v-if: bare or wrapped, string literals quoted -->
<label v-show="status == 'active'">Active</label>

Use v-show when you want to avoid layout shifts on state changes. Use v-if when the element should never reserve space.


Attribute binding

Bindings work in attributes too:

<img src="{{ player.avatarPath }}"/>
<item-slot item="{{ player.heldItem }}"/>
<input value="{{ config.defaultName }}"/>

If an attribute binding cannot be resolved, the literal {{ ... }} text remains in the attribute. For handler attributes such as onclick="{{ actionKey }}", TesseraUI logs a warning because the button would otherwise be silently inert.


Stateful inputs across rebuilds

Templates are rebuilt from scratch when you call TesseraTemplateRenderer.build(...). To keep input text, cursor, selection, and focus across rebuilds:

  1. Give every stateful <input> / <textarea> a stable id.
  2. Keep one TesseraRenderContext field on your screen.
  3. Pass that context into the renderer.
<input id="search" value="{{ search.default }}" oninput="onSearch"/>
private final TesseraRenderContext renderContext = new TesseraRenderContext();

root = TesseraTemplateRenderer.build(
    TesseraTemplate.load("yourmod:ui/search"),
    model,
    clickHandlers,
    inputHandlers,
    renderContext,
    x, y, w, h
);

If a context is provided and an <input> or <textarea> has no id, TesseraUI logs a warning because that widget cannot persist state.


Model helpers

Building from a list

Map<String, String> data = new HashMap<>();
data.put("entries", String.valueOf(entries.size()));
for (int i = 0; i < entries.size(); i++) {
    data.put("entry.name."  + i, entries.get(i).getName());
    data.put("entry.count." + i, String.valueOf(entries.get(i).getCount()));
}
TesseraModel model = TesseraModel.of(data);

Lambda model

For dynamic resolution:

TesseraModel model = key -> {
    if (key.equals("time")) return LocalTime.now().toString();
    return null;
};

Clone this wiki locally