Stardew Markup Language (StarML)
StarML is the markup language used by the UI Framework. It is an HTML-like syntax based on a tree of elements, each corresponding to a view.
It shares many traits with Angular templates, particularly an enhanced set of attributes that perform data binding and other special functions.
Example
<frame layout="850px 500px"
background={@Mods/StardewUI/Sprites/MenuBackground}
border={@Mods/StardewUI/Sprites/MenuBorder}
border-thickness="36, 36, 40, 36">
<lane layout="stretch content" orientation="vertical">
<label font="dialogue" text="Hello from StardewUI!" />
<label text={IntroParagraph} />
<textinput layout="200px content" text={<>FarmerName} />
<image layout="400px 100px" sprite={@Mods/TestMod/Sprites/Hello} />
<button text="Launch" click=|LaunchCoolFeature("now")| />
</lane>
</frame>
Elements
As with most markup languages, StarML is built around elements. An element is:
- A single tag
- with any number of distinct attributes
- and zero or more children.
Every StarML element corresponds to a View, whose type is decided by its tag; every attribute corresponds to a property or event on that view (except structural attributes), and child elements correspond to the view's child views.
Like HTML and XML, elements can either have an explicit closing tag, or be self-closing:
There are no strict rules around the use of opening/closing vs. self-closing tags—any element can be written using either style. The difference is that self-closing tags cannot have any children; thus tend to be used for "simple" views such as <label />
and <image />
, while open tags are used for layout views like <frame>
, <lane>
and <panel>
.
Because the UI Framework is an abstraction over regular Views, it will not enable you to do anything with a View that would be impossible or prohibited in the Core Library, such as add children to a non-layout view.
Tags
A tag is anything between a pair of angle brackets < >
, one of:
- An opening tag:
<panel>
- A closing tag:
</panel>
- A self-closing tag:
<panel />
.
While the term "tag" may sometimes be used synonymously with "element", tags refer more narrowly to the specific markup above, i.e. not including any attributes or children.
In StarML, tags are not arbitrary; except for <include>
, the tag defines the specific type of view that is to be created, which in turn determines what attributes are allowed and how many children it is allowed to have.
These are the standard tags available in the UI framework:
Note
The full list of tags currently supported can always be found in the ViewFactory source.
Tag |
View/Behavior |
Description |
---|---|---|
<banner> |
Banner | Displays a banner, aka "scroll", using a cartoonish font. Background optional. |
<button> |
Button | Simple raised button with optional hover effect. |
<checkbox> |
Checkbox | Checkbox with optional clickable label. |
<digits> |
Tiny Number Label | Displays a number in extra-small font; used for item quantities. |
<dropdown> |
Drop-Down List | Select from a list of options. |
<expander> |
Expander | Can be clicked to show or hide more content. |
<frame> |
Frame | Draws a border and/or background around another view. |
<grid> |
Grid | Uniform grid layout using either fixed size per item or fixed number of items per row/column. |
<image> |
Image | Displays one image using a variety of scaling and fit options. |
<include> |
Included View | Insert a different StarML view in this position, using its asset name to load the content. |
<keybind> |
Keybind | Displays the buttons bound for a single keybinding. |
<keybind-editor> |
Keybind Editor | Displays and allows rebinding of all button combinations in a keybind list. |
<label> |
Label | Displays single- or multi-line text using a standard SpriteFont . |
<lane> |
Lane | Arranges other views along one axis, either horizontal (left to right) or vertical (top to bottom). |
<marquee> |
Marquee | Animates scrolling text or other content horizontally; named after the HTML Marquee. |
<outlet> |
N/A | Default or named template outlet. Only valid within a <template> element. |
<panel> |
Panel | Displays all children as layers ordered by z-index. Positions can be adjusted using margins. |
<scrollable> |
Scrollable View | Shows scroll bars and arrows when content is too large to fit. |
<slider> |
Slider | A numeric slider that can be moved between a minimum and maximum range. |
<spacer> |
Spacer | Draws nothing, but takes up space in the layout; used to "push" siblings to one side. |
<tab> |
Tab | Push-down tab used to select the active section or page of a larger menu. Can be rotated for side navigation. |
<template> |
Template | Defines a custom tag for replacement. Must be at the document root; not valid as a child of any other element. |
<textinput> |
Text Input | Input box for entering text; includes on-screen keyboard when activated by gamepad. |
Attributes
Tags define what view will be created; attributes define how it will look and behave, and specifically its properties and events.
An attribute is any string appearing inside a tag that has the form attr=value
, where value
is one of the supported flavors, such as a quoted string like text="Hello"
or data binding expression like text={HelloMessage}
.
There are also structural attributes which are a separate topic.
Common Attributes
Note
These are the attributes common to all types of views (tags). Specific views usually have additional properties. Refer to the standard views documentation for details.
In StarML, the name of an attribute is always the kebab-case version of the property name, e.g. HorizontalContentAlignment
becomes horizontal-content-alignment
.
Attribute | Direction (1) | Type (2) | Explanation |
---|---|---|---|
actual-bounds |
Out | Bounds |
True outer bounds of the view relative to its parent, including margins. |
border-size |
Out | Vector2 |
Size of the view's content plus padding and border width. |
content-bounds |
Out | Bounds |
True bounds of the view's content relative to its parent, excluding margins. |
content-size |
Out | Vector2 |
Size of the view's content, not including padding, borders or margins. |
draggable |
In/Out | bool |
Allows this view to receive drag events (drag , drag-start and drag-end ). |
focusable |
In/Out | bool |
Allows this view to receive focus, or "snap", when using a gamepad. Automatically enabled for most interactive elements like button or dropdown . |
inner-size |
Out | Vector2 |
Size of the view's content and padding, excluding borders and margins. |
layout |
In/Out | LayoutParameters |
The intended width and height. See conversions for allowed values. |
margin |
In/Out | Edges |
Pixel sizes for whitespace outside the border/background. |
name |
In/Out | string |
For <include> elements, the view's asset name; for all other elements, a user-defined name used mainly for logging and troubleshooting. |
opacity |
In/Out | float |
Opacity (alpha) value of the entire view, from 0.0 (fully transparent/hidden) to 1.0 (fully opaque). |
outer-size |
Out | Bounds |
Total layout size occupied by the element, including padding, borders and margins. |
padding |
In/Out | Edges |
Pixel sizes for whitespace inside the border/background. |
pointer-events-enabled |
In/Out | bool |
Can be set to false to prevent receiving clicks, mouseovers, etc. Use when a transparent view is drawn on top of an interactive view. |
scroll-with-children |
In/Out | Orientation |
Forces the entire view to be visible when navigating in the specified direction. See the ScrollableView documentation for details. |
tags |
In/Out | Tags |
Allows arbitrary data to be associated with the view. Not supported in StarML yet - may be supported in the future. |
tooltip |
In/Out | TooltipData |
Tooltip to show when hovered with the mouse, or focused on via game controller. |
transform |
In/Out | Transform |
Local transform to apply to the view. |
transform-origin |
In/Out | Vector2 |
Relative origin position for the transform ; see Transform Origin. |
visibility |
In/Out | Visibility |
Whether to show or hide the view; hiding does not remove it from the layout, use *if for that. |
z-index |
In/Out | int |
Drawing order within the parent; higher indices are drawn later (on top). |
-
In/Out properties can accept any directional binding modifier; Out properties are read-only and can only be used to write to the model, e.g. if you need to receive the view's actual pixel size after a layout.
Attempting to bind an
Out
property without the>
modifier, or attempting to assign it a literal value with a="value"
type attribute, will cause it to fail. -
StardewUI's views will often have property types that can't be carried across the API boundary due to current limitations on SMAPI and its version of Pintail.
To help you through this, automatic conversions to and from other common types, such as tuples and XNA/MonoGame structures, are often provided if you want to bind one to your model and would rather not make everything a
string
.Note that some conversions may be lossy, i.e. if there is a difference in numeric precision.
Structural Attributes
Structural attributes look like regular attributes, but with a *
prefix. Instead of binding to properties or events, they control aspects of how the view tree is constructed.
Attribute | Expected Type | Description |
---|---|---|
*case |
Any | Removes the element unless the value is equal to the most recent *switch . The types of *switch and *case must either match exactly or be convertible.Can be negated. |
*context |
Any | Changes the context that all child nodes bind to; used for heavily-nested data models. |
*if |
bool |
Removes the element unless the specified condition is met. Can be negated. |
*float |
FloatingPosition |
Makes the element a floating element. |
*outlet |
string |
Specifies which of the parent node's outlets will receive this node. Does not support bindings. The attribute value must be a quoted string. |
*repeat |
IEnumerable |
Repeats the element over a collection, creating a new view for every item and setting its context to that item. Applies to both regular and structural attributes; e.g. if *repeat and *if are both specified, then *repeat applies first. |
*switch |
Any | Sets the object that any subsequent *case attributes must match in order for their elements to show. |
Negation
Structural attributes linked to conditional behavior can be negated, by placing a !
before the assignment. For example:
The above would cause the label to only be displayed when the condition is false
, rather than true
. Similarly, negating a *case
attribute as *!case
would cause it to only display its content when the value does not match.
Negation is only allowed for specific conditional attributes, such as *if
and *case
. Consult the structural attributes table above for the attributes that say "can be negated".
Behavior Attributes
Attributes beginning with a +
character are Behaviors, which are independent entities able to act on some view, similar to the way a C# extension method acts on its target.
The behavior system is designed to be extensible through add-ons, so unlike structural attributes, there is not necessarily a universal list of valid behaviors. The table below covers the behaviors that are available in "vanilla" StardewUI, without any add-ons. Behaviors generally take an argument in addition to their attribute value, following a :
separator in the attribute name.
Behavior | Argument | Value Type | Description |
---|---|---|---|
hover:<arg> |
Any regular attribute | Same as <arg> attribute |
Changes the <arg> property to a new value when the pointer enters the view, and reverts it when the pointer leaves the view. |
show:<arg> |
Any regular attribute | Same as <arg> attribute |
Changes the <arg> property to a new value when the view becomes visible, including when first created e.g. as the result of an *if ; reverts it when the view is hidden. |
state:<name> |
string |
bool |
Creates a named state whose behaviors are toggled on/off by the bound value. This is intended for use with state triggers (see below). |
state:<name>:<arg> |
string , then any regular attribute |
Same as <arg> attribute |
Changes the <arg> property to a new value when the state with specified name becomes active, and reverts it when the state becomes inactive. Only works when state:<name> is defined on the same element. |
transition:<arg> |
Any regular attribute | Transition | Applies a transition to the <arg> property when its value changes, causing it to animate gradually from its current value to the new value instead of changing immediately. |
Events
Event attributes look similar to property attributes, but deal specifically with .NET events raised by views. More generally, they are one of the two ways it is possible for the UI to communicate something back to your mod (the other being output/two-way bindings).
While some UI might be purely informational (e.g. a tooltip or HUD), any interactive UI will probably involve one or more event bindings.
Bindings for events use a specific flavor, where the handler and its arguments are enclosed in a pair of pipes (|
):
To experienced C# programmers, this may look like an ordinary method call, but it isn't. Event bindings are a powerful and complex feature, and reading the documentation on them is strongly recommended before using them.
Note
The events described below are the events common to all types of views (tags). Specific views may have additional events. Refer to the standard views documentation for details.
In StarML, the name of an attribute is always the kebab-case version of the event name, e.g. LeftClick
becomes left-click
.
Event | Arguments Type (1) | Condition |
---|---|---|
button-press |
ButtonEventArgs |
Any keyboard/gamepad button pressed. |
click |
ClickEventArgs |
Any mouse/gamepad button clicked. |
drag |
PointerEventArgs |
Ongoing drag operation; mouse was moved while button is still held. |
drag-end |
PointerEventArgs |
End of a drag operation, mouse button was released. |
drag-start |
PointerEventArgs |
First frame of a drag operation. |
left-click |
ClickEventArgs |
Left mouse button or controller A was pressed. |
pointer-enter |
PointerEventArgs |
Cursor just moved inside the view's bounds. |
pointer-leave |
PointerEventArgs |
Cursor just moved outside the view's bounds. |
pointer-move |
PointerMoveEventArgs |
Cursor moved within a view's bounds after entering. |
right-click |
ClickEventArgs |
Right mouse button or controller X was pressed. |
wheel |
WheelEventArgs |
Mouse wheel movement was detected. |
- Provided here as a reference for looking up the properties in Events Source. You don't consume these types directly in your event handlers; consult the Event Docs for details on how to set up handlers.
Attribute Flavors
Regular HTML uses quoted attributes; to support the more complex behaviors where the attribute value should not be interpreted literally (as in, the exact value inside the quotes), StarML uses different "flavors" of attributes using different punctuation.
Bindings are not tokens
Those accustomed to Content Patcher Tokens may need to unlearn certain habits, because what goes on behind the scenes with StarML is far more complicated than string replacement. If you attempt to write "tokens" of the form attr="A {{value}} B"
, you are going to be disappointed.
Format |
Meaning |
---|---|
attr="value" |
The literal (converted) value inside the quotes. |
attr={PropertyName} |
The current value of the specified context property. |
attr={@AssetName} |
The current content of the named asset. |
attr={#TranslationKey} |
The translated string for a given translation key. Can be either unqualified (foo.bar ) if referring to a translation in the same mod that provided the view, or qualified (authorname.ModName:foo.bar ) if referring to a translation in any other mod. |
attr={&templateParam} |
Replace with a template attribute of the same name. Only valid in a <template> element. |
attr=|Handler(Arg1, ...)| |
Call the specified context method, with the specified arguments; only valid for event attributes. |
Note
Double-braces ({{
and }}
) are allowed in place of single braces, for those heavily accustomed to Content Patcher syntax and JSON tokens in general, but are not recommended due to the inconsistency with other attributes and reduced readability.
Binding Modifiers
In addition to the different attribute flavors, context binding attributes—that is, those of the form attr={PropertyName}
—can use modifier prefixes to either redirect to a different context or change the direction of synchronization between context and view.
These modifiers work only with context property and event bindings; they cannot be used on literal attributes or assets.
Modifier | Example |
Effect |
---|---|---|
^ |
{^Prop} |
Binds to the parent context instead of the current context. Multiple ^ characters can be appended to go farther up, e.g. ^^^Prop . |
~ |
{~Foo.Prop} |
Binds to the typed ancestor instead of the current context. |
< |
{<Prop} |
Specifies an input binding, where the view receives its value from the model but does not write back. This is the default behavior when no modifier is used, and can generally be omitted. |
: or <: |
{:Prop} |
Specifies a one-time input binding, which is the same as an ordinary input binding except that subsequent changes to the value will be ignored. |
> |
{>Prop} |
Specifies an output binding, where the view writes its value to the model but does not read back. |
<> |
{<>Prop} |
Specifies an in/out binding, where the view both receives its value from the model and writes back to the model. |
Tip
Context modifiers can be combined with direction modifiers, but order matters; the direction must come first. You can write {<>^^Prop}
or {>~Foo.Prop}
, but not {^^<>Prop}
or {~>Foo.Prop}
.
Type Conversions
In the SMAPI world, integrations between mods, including framework mods, are accomplished by duck typing, specifically through the Pintail library. This is a highly effective system for backward-compatibility and in some cases forward-compatibility; however, it presents many challenges for a UI library that uses many complex types.
Many attribute types like Bounds
and Edges
simply can't be transmitted across the API boundary, even if you copy their definitions. Interface types such as IView
are far too complex to copy over. StardewUI doesn't want you to have to deal with these issues on a one-off basis. Instead, it has a highly integrated system of type conversions: regardless of the actual, real type of a view's property or event argument, it can be assigned or bound to any property with a convertible type.
In the table below, the String Format is what you can put in a literal attribute or a string
typed context property; Converts From can be used with input bindings and Converts To can be used with output bindings or event arguments. For two-way bindings, the type must either be string
or be in both the "from" and "to" lists.
Note
All primitive types (numeric and bool
) can be converted from their string representation, and are not shown explicitly in the conversion table. All types can also be converted to a string, though whether or not the string is useful depends on its ToString()
implementation.
indicates a possible loss of numeric precision.
Type | String Format | Converts From | Converts To |
Any enum |
(Field Name) | N/A | N/A |
Point |
"x, y" |
N/A | N/A |
Vector2 |
"x, y" |
N/A | N/A |
Rectangle |
"x, y, width, height" |
N/A | N/A |
LayoutParameters |
"<Length>" (1) |
N/A | N/A |
"<Width> <Height>" |
|||
"<...>[Min..]" (12) |
|||
"<...>[Min..Max]" |
|||
"<...>[..Max]" |
|||
Length |
"<num>px" (2) |
N/A | N/A |
"<num>%" (3) |
|||
"content" (4) |
|||
"stretch" (5) |
|||
Edges |
"left, right, top, bottom" |
Tuple<int, int, int, int> |
Tuple<int, int, int, int> |
Vector4 |
Vector4 |
||
int |
|||
Point |
|||
Tuple<int, int> |
|||
Vector2 |
|||
Tuple<Point, Point> |
|||
Tuple<Vector2, Vector2> |
|||
Bounds |
N/A | N/A | Tuple<float, float, float, float> |
Tuple<Vector2, Vector2> |
|||
Vector4 |
|||
Rectangle |
|||
Color |
"#rgb" |
N/A | N/A |
"#rgba" |
|||
"#rrggbb" |
|||
"#rrggbbaa" |
|||
Sprite (9) |
N/A | Texture2D |
N/A |
Tuple<Texture2D, Rectangle> |
|||
ParsedItemData |
|||
SpriteFont |
"dialogue" (6) |
N/A | N/A |
"small" (7) |
|||
"tiny" (8) |
|||
Transform |
See Transforms | N/A | N/A |
Transition |
[duration] [delay] [easing] (14) |
N/A | N/A |
Visibility |
"Visible" | bool |
N/A |
"Hidden" | |||
TooltipData |
Any | string (text only) |
N/A |
Tuple<string, string> (title + text) |
|||
Item |
|||
ParsedItemData |
|||
GridItemLayout |
"count: n" (10) |
N/A | N/A |
"length: n" (11) |
|||
"length: n+" |
|||
FloatingPosition |
"above" |
Func<Vector2, Vector2, Vector2> See offsetSelector |
N/A |
"below" |
|||
"before" |
|||
"after" |
|||
"<edge>; X, Y" (13) |
- Applies the same value to both the
Width
andHeight
. SeeLength
conversions below for what values are allowed for<Length>
,<Width>
or<Height>
. - Specifies an exact value in pixels, e.g.
100px
. - Percentage of the container's available width or height, e.g.
50%
. - As wide/tall as the content wants itself to be, up to the available container size.
- Use the entire width/height available, after any siblings that are not stretched.
- Reference to
Game1.dialogueFont
- Reference to
Game1.smallFont
- Reference to
Game1.tinyFont
- Sprites can be bound to model properties, but should only be done for sprites that must be dynamic. In the majority of cases, you should use sprite assets instead.
n
is any positive integer; lays out the grid using n items per row/column and adjusts their size accordingly.n
is any positive integer; lays out the grid using a fixed width/height ofn
per item, and wraps to the next row/column when reaching the end.If the value ends with a
+
, the size will be expanded so that the total space used by all rows/columns is exactly equal to the grid's layout width/height.- Any
Length
(width, height or both) can have a range appended to it specifying the min and/or max, such as50%[100..800]
, meaning "prefer 50% of container, but constrained between 100px and 800px". Use open ranges to specify only a minimum, or only a maximum. - Any of the edges (
above
,below
,before
orafter
) can have aVector2
-compatible offset appended to it, e.g.above; 5, 8
which adds the specified offset to the computed edge position. This can be used in place of margins to add more space between floating elements and their parents. -
All fields are optional, but any that are present must appear in the specified order.
- Duration and delay are numbers that end with either "s" (seconds) or "ms" (milliseconds), such as
1200ms
or1.2s
. - Easing can be a named function, including any of the functions on easings.net, or a custom easing of the form
CubicBezier: x1, y1, x2, y2
. For more information on cubic béziers, refer to the CSS reference.
Examples:
300ms
(defaults to linear easing, no delay)300ms EaseOutCubic
300ms 50ms EaseOutCubic
EaseOutCubic
(defaults to 1 sec, no delay)
- Duration and delay are numbers that end with either "s" (seconds) or "ms" (milliseconds), such as
If a type shows "N/A" for conversions, that means no conversion is available, either because it is not meant to be used in that scenario, or because it is already a shared type. Shared types such as any of the XNA/MonoGame types can be used directly in your model and therefore don't require any conversions, except from string
to be used in literal attributes.
Duck Typing
If a particular type conversion is not in the table above, it may be available for automatic implicit conversion. See the page on duck typing for rules and additional information on when and how this occurs.
Children
In StarML—as in HTML or XML—an element's children are any tags appearing between the parent element's opening and closing tags:
In the above example:
<label text="Title" />
and<frame>
are children of the<lane>
<label text="Content"
is a child of the<frame>
In general, only layout views can have children; attempting to add children to any other view type will cause an error.
Children can only be added to elements with separate opening and closing tags, e.g. <lane>...</lane>
. Any self-closing tag, even if it corresponds to a layout view, cannot contain children, because a self-closing tag cannot be paired with a regular closing tag; just as with HTML or XML, <lane/>...</lane>
is simply invalid StarML and will fail to parse.
Because of these constraints, all documentation and examples on this site use opening/closing tags for layout views, and self-closing tags for other views. While this is not a requirement for valid StarML, it is recommended that you do the same in order to avoid confusion and lower the chances of creating invalid markup.
Child Limits
Some layout views can have only one child, for example frames and scrollables. That means the following is invalid markup:
This will parse, but will either fail to display at all or fail to display correctly, because the actual frame view only has a single Content
view, not a list of views like a lane or panel.
However, this rule applies only to the constructed view, not the markup itself. If only one of the children can actually display at a time, then there is no problem.
Risky
This is a "maybe" because, while you might personally know – or expect – that Item1Visible
and Item2Visible
cannot both be true
at the same time, the framework itself does not know that and cannot enforce it, and doing it this way could cause it to fail when you least expect it, e.g. long after your mod has been released and been downloaded several times.
A better way is to use *switch
:
Success
This version cannot fail because VisibleItem
cannot be both Item1
and Item2
at the same time. In other words, it is always OK to have multiple child nodes underneath a single-view layout, if all of those nodes have a distinct *case
. Otherwise, there is the possibility of failure.
Outlets
Children are grouped into "outlets", which represent specific areas or subcomponents of a layout.
Usually, this is invisible to you, as a layout view only performs one type of layout and therefore only has one child or collection of children. However, there are a few exceptions; one of them is the Expander, which allows specifying both a header view (the part that is always shown) and the content view (the part that can collapse).
To solve for these problems, StarML supports the *outlet
structural attribute, which allows targeting a specific outlet with a specific element:
<expander>
<button *outlet="header" text={ExpandCollapseText} />
<label layout="stretch content" text={LongContent} />
</expander>
Outlet names are determined by the view itself, via the OutletAttribute being applied to specific properties. In the case of Expander
, there is one named outlet for the Header property, named "header". Custom views can create named outlets using the same attribute.
If an *outlet
is not specified in the markup, then the default (unnamed) outlet is assumed. When multiple outlets are available, the child limits still apply per-outlet; if any given outlet, default or named, only allows a single view, then attempting to assign multiple views to that outlet would be invalid. For example, the following would not be allowed for an <expander>
element:
Broken
This is not allowed because the expander's header
outlet requires a single content view. However, if this were to use an *if*
condition then it would be valid again:
Success
If there is a real need to have multiple views in the outlet, then this can also be achieved using a single layout view to hold them:
Success
Comments
HTML-style block comments may be used in StarML documents, using the same syntax.
<!--
begins a comment and -->
ends a comment; the content of a comment can be a single line or multiple lines.
Example
<--! My Menu -->
<lane orientation="vertical" horizontal-content-alignment="middle">
<banner text="Menu Title" />
<!-- Nav Bar -->
<lane layout="200px content">...</lane>
<!-- Main Content -->
<frame>...</frame>
<!--
Spacer is added at the end of the lane to re-center the
main content after having it pushed right by the nav bar.
-->
<spacer layout="200px 0px" />
</lane>
Unlike code comments, StarML comments will be visible to users of your mod if they open your .sml
directly, since those files are simply ordinary content files. However, they are ignored after parsing and are not shown on screen or held in memory.
Why not HTML?
Some web frameworks, like Vue, are a subset of HTML; their templates are syntactically valid HTML, constructed in such a way that any special behavior can be understood using special tags, attributes with unusual but valid prefixes like :class
, and so on. So, why not simply pull in an HTML (or XHTML) parser and use that?
This went through careful consideration but ultimately seemed to have more negatives than positives. The potential positives:
- Several preexisting parsers are available for .NET;
- Common editors (Visual Studio Code, Notepad++, etc.) provide built-in syntax highlighting and validation;
- Already familiar to anyone with web development experience.
The negatives:
- Using a third-party parser (or any third-party library) in a SMAPI environment is risky, and most parsers are not totally optimized for memory and speed.
- Common editors provide built-in syntax highlighting and validation that could be misleading given the real constraints of a Stardew UI. For example, interpolations like
attr="{value1} {value2}"
are valid HTML but not actually supported. - Prior web development experience only helps through the shallowest part of the learning curve, not with binding/event attributes, model design, asset organization, etc.
- If all attributes are quoted, then character escaping becomes a more significant issue, especially for literal expressions; imagine documents full of
.
- An HTML parser only breaks the raw text down into stringly-typed elements and attributes; StardewUI would then need to do a second round of parsing (i.e. twice as much work) on all the tags and attribute values to determine which attributes are standard vs. structural, which values are literals vs. bindings vs. events, determine all the property names and types involved in an event handler, and so on.
- The actual runtime model (widget tree) is not comparable to an HTML DOM; does not understand unknown tags or standard HTML tags, does not distinguish between block/inline/other styles, and so on. As an abstraction over native UI, the inner workings are more like Qt or Android than a web browser; therefore, an "it's just HTML" approach would eventually end up causing more friction and confusion than a custom language.
What about XML?
An alternative would be something similar to XAML, but these have a way of getting out of control; consider, for example, a "simple" binding redirect in XAML:
<Label Content="{Binding Path=DisplayName, RelativeSource={RelativeSource Mode=FindAncestor AncestorLevel=1}}"/>
<Label Content="{Binding Path=DisplayName, RelativeSource={RelativeSource Mode=FindAncestor AncestorType={x:Type Foo}}}"/>
Compare to a StarML context redirect:
And this is without any binding modifiers which add even more noise to XAML.
This is not intended as a slight against XAML, which is actually a very powerful and mature tool for cross-platform development and far better than most of the available alternatives. However, it reflects a different use case: backing an enormous framework (WPF) that is difficult to update once released, needs to scale to almost any imaginable UI scenario, and can expect a very high level of expertise from dev-users.
It is precisely because XML is so extensible that XML-based languages become convoluted over time; they encourage a way of thinking wherein new features are designed to fit into the XML structure somehow, even if the existing syntax is already confusing, verbose or requires some separately-interpreted micro-grammar, instead of the alternative of making a tiny change to the parser to support a relatively simple addition, like the <>
or ^
modifiers above.
StardewUI is a mod, which means it is easy to change the parser, easy to roll out said updates, and has fewer users and UI scenarios to cover, so more effort can go into making easy things easy, instead of making nearly-impossible things possible. For scenarios that StarML can't cover, the Core Library is the escape hatch.
Why not JSON?
In many ways, JSON is the lingua franca of Stardew modding; it is the notation used by every content pack, not only for Content Patcher but for every other framework out there. Why should StardewUI be different?
While sharing some of the same concerns as HTML, such as double-parsing and character-escaping, it does route around other issues such as third-party libraries (Json.NET is technically third-party, but is already available in every SMAPI environment).
To understand why it's not a good fit, we can take a look at what kind of structure might be required for a very simple view. In this example, we display a frame border, a single header line, and the names of a list of NPCs.
Notice how much longer, more verbose, and more indented the JSON version is? Now picture how it would look in a view that is 5 levels deep, or 10. This is also the best case scenario for syntax, assuming we make some negative tradeoffs on the parsing side and use JObject
instead of any concrete type, which is what allows us to use dictionary-style attributes for Orientation
, Font
and so on instead of defining yet another "Attributes": [...]
array with objects inside it.
At scale, the JSON version becomes unreadable; it is difficult to look at this data and even understand what it is supposed to do in broad strokes, to say nothing of keeping track of which indentation level you're at, what the context is at that level, etc. JSON can be used to represent a syntax tree, but it is not very good at it (or any other tree).
JSON is native to the web; the "J" stands for "JavaScript", and yet it is not used by any mainstream web frameworks for defining the UI. This is because, while JSON is a good format for data storage, a full user interface cannot be represented very efficiently as plain data; it is not what we call ergonomic. That is exactly the problem that markup languages like HTML evolved to solve, and why we see similar mechanisms continue to evolve organically in every domain from .NET to Qt to Rust.