Writing your own Rich Text system.
This guide aims to take you through the steps and thought process of creating a module for parsing text and building UI for rich text purposes. Many steps of this guide may be skipped, however for educational purposes, it is recommend you follow all of it.
Introduction
Hey, I’m Stef, I’m a Roblox game developer, and a computer science university student. I’ve worked on a couple of games during my time on Roblox, and in a few of those, I have felt the need to spice up my UI, typically dialogue, through some custom rich text effects.
Having implemented such a system from scratch multiple times, I am all too familiar with how lost one can feel, when faced with the task.
In this guide I plan to walk you through the steps and thought process, which will ultimately lead to you creating your very own (modular) rich text system
I will be covering the following:
- Terminology
- Parsing Text
- Defining Syntax
- String patterns
- Stack data structure
- Implementation
- Error handling
- Building UI
- Creating Labels
- Implementing an example tag
- Data structure
- Wrapping up
I hope you will indulge me by sticking with me throughout this guide, and I hope you learn something valuable. Let’s get started.
Terminology
Throughout this guide I will be using some terminology to more effectively communicate the process. I expect you to be familiar with common Luau terminology, however I will elaborate on some terms here.
Term | Definition |
---|---|
Tag | A series of characters that denotes a change in appearance of the rich text, rather than actual textual content. |
Pattern | A series of characters that denotes a special syntax or pattern, similar to Regular Expressions. |
Parsing Text
Defining Syntax
In order to create our rich text system, we need to first be able to parse our input text, and identify what is tags and what is literal text. To do this, we need to first denote how we want our syntax to be.
We may get some inspiration in this regard from Roblox’ own rich text. If you have worked with XML or HTML, this should be familiar to you.
If we look at the documentation for Roblox’ rich text markup, we see that tags are denoted by wrapping the name of the tag in less-than and greater-than signs. For demonstration purposes, I will be using the common <b>
tag, which typically denotes boldness.
We may also note that tags are separated into opening and closing tags, where closing tags are denoted by prefixing the name of the tag with a forward slash. Using the bold tag as an example again, a closing tag may look like this: </b>
.
All opening tags must be accompanied by a closing tag, and all text between a pair of tags will be formatted.
So if we want to create some bold text using Roblox’ rich text, we would write the following:
<b>this text is bold</b>
Additionally, some tags can have attributes. Taking an example from the documentation, the <font>
tag has various attributes, one of which being color
, which allows us to change the color of the text.
These attributes are denoted like so:
<font color="#00FF00">this text is green</font>
Take note that we only define attributes in opening tags.
And that’s it! It may be simple, but this syntax is all we need.
If you wish, you can change the syntax within your own implementation, however I will stick to what we have outlined here.
String patterns
Now that we have defined our syntax, we can move on to the interesting part. So let’s take a step back for a moment and consider what our goal is.
For now we aren’t concerned with how we will actually render our text, but only parsing our input text so we are able to process it. To do this we need to be able to separate tags from regular text. And our best tool available to achieve this is string patterns.
If we can prepare a string pattern that can identify tags for us, we can easily go through any inputted string and isolate all tags.
And we can! Using the string patterns reference we have everything we need. So let’s start preparing our pattern.
First of all, we know any tag has to start with <
and end with >
, so that will be our starting point.
"<>"
We also need to account for the characters inside the tag, so let’s use .-
, which denotes any amount of any character, but finds the match with the fewest.
"<.->"
With this, we can find any instance within a text, where some (or none) characters are wrapped in less-than and greater-than signs!
But we can do better than that!
Let’s consider what will actually be inside the tags.
The first thing inside any tag, will be the name of the tag itself. For simplicity, we will limit these names to be alphabetical only, and we will make it case-insensitive.
We can achieve this using the pattern %a+
, which denotes 1 or more, upper- or lower-case, letters.
Replacing our naive solution with this, we have:
"<%a+>"
Next up, it’s time to account for closing tags too! Remember that these are denoted by a prefixed forward slash. We may use the ?
quantifier to denote that we expect 1 or 0 preceding forward slashes.
"</?%a+>"
We’re almost there, but not quite yet! We also need to consider the possibility of there being attributes defined.
This is a good time for me to clarify something from the terminology. I compare string patterns to Regular Expressions, however the truth is, string patterns are a lot less powerful, and support a lot fewer features. Because of this, we are going to split our pattern into two separate patterns here.
But first we need to finish the one we are working on right now.
We know that anything after the name of the tag, must be attributes, otherwise the tag is invalid. To handle this, we use a capture group.
"</?%a+(.-)>"
This pattern, when used with string.match
, will allow us to not only verify that the tag (to some extent) adheres to our syntax, but will also let us grab the substring, which is the attributes.
We can then match another pattern onto this substring, in order to verify that the tag is indeed valid.
This is possible with a pattern that matches just a single attribute, so let’s make that.
We know that each attribute consists of at least one letter, followed by an equals sign, which is then followed by any amount of any characters wrapped in double quotes. I am also going to allow any amount of whitespace between and section of the attributes.
Once we then apply the start-anchor to this pattern, this results in:
"^%s+%a+%s*=%s*\".-\""
Do note, we are prefixing the quotes within the pattern with a backslash in order to escape the character, this is necessary since patterns are just strings at their core.
And while we’re at it, let’s allow for any amount of whitespace between the wrapping less-than and greater-than signs, and the content of the tag, in the original pattern.
This leaves us with our final two patterns:
"<%s*/?%a+(.-)%s*>"
"^%s+%a+%s*=%s*\".-\""
Now let’s actually think about what we are doing for a second. Was this all really necessary? Couldn’t we just have settled for a simpler pattern? Well yes, we could.
Strictly speaking, anything past
"<.->"
Just isn’t necessary.
So why?
Well, just because our fancy patterns aren’t necessary, doesn’t make them useless. We can use these patterns we have created in order to validate our syntax, which will make error handling a lot easier!
In our final implementation, we will actually use all of these last 3 patterns!
Stack data structure
Now that we know how to identify tags, we need to figure out how we are going to keep track of them! While there are a couple of different ways to approach this, let’s consider what we need!
We are going to be processing text left-to-right, and we need to keep track of which tags (and with what attributes) are currently in effect. We also need to be able to easily identify which tag opened last, in order to validate closing tags.
The experienced among you may know where I am going with this, especially considering the title of this section, but we are going to be using a data structure called a Stack!
If you are not familiar with this data structure, don’t worry! It’s very simple. All you need to know is that it’s similar to a table, but you can only remove elements in a LIFO order.
For the sake of brevity (this guide is already pretty long) I will not walk you through the implementation of this data structure. Instead I will attach an implementation made by myself. If you are feeling up to the task, feel free to read through it, and consider how it works.
Stack.lua
--!strict
local Stack = {}
Stack.__index = Stack
Stack.__metatable = true
type self = {
_data: {any}
}
export type Stack = typeof( setmetatable({} :: self, Stack) )
function Stack.new(): Stack
local self = setmetatable({},Stack)
self._data = {}
return self
end
function Stack:Push(Element: any): ()
table.insert(self._data,Element)
end
function Stack:Pop(): any
if #self._data == 0 then error("Attempted to call Pop on an empty stack",2) end
local Value = self._data[#self._data]
self._data[#self._data] = nil
return Value
end
function Stack:Peek(): any
if #self._data == 0 then error("Attempted to call Peek on an empty stack",2) end
return self._data[#self._data]
end
function Stack:Empty(): boolean
return #self._data == 0
end
function Stack:Search(Element: any): number
local Index = table.find(self._data,Element)
if not Index then return -1 end
return #self._data - Index + 1
end
function Stack:__tostring(): string
return `Stack({self._data[#self._data]},#{#self._data})`
end
function Stack:__iter(): (typeof(next),{any})
return next, self._data
end
return Stack
We are going to be using this data structure, and we will push tags onto it, whenever we encounter them, as we walk through our text.
Implementation
Now that we have assembled the tools we are going to use, we will begin writing our implementation of our text parser!
The goal here is to create a function that can separate tags from regular text content in our input string. Let’s say we want out result in the form of a table, where each entry in the table is either a tag or the text between tags.
Let’s create a boilerplate for this, as well as some types we will be using throughout the implementation. I am also going to put this in a module script, and require the Stack module.
--!strict
local RichText = {}
local Stack = require(script.Stack)
type Array<T> = {T}
type Tag = {
Type: "TAG",
Name: string,
Opening: boolean,
Content: {[string]: string},
}
type Text = {
Type: "TEXT",
Content: string,
}
function RichText.ParseText(Input: string): Array<Tag|Text>
local Tokens = {}
return Tokens
end
return RichText
Now we can begin our implementation!
First let’s consider our approach. We want to process the entire string, from left-to-right, and we want to do so in chunks, one chunk being either one tag, or the text between two tags (or until the end of the string).
We can achieve this by using a while loop, and processing the first chunk in the string, and then removing said chunk from the string. Once the input string is empty, we have processed all of it!
The first step is to check where the next tag is in the string. If this tag is at the very start of it, we will process it, otherwise, we are going to process all the text leading up until the tag we found!
This can be done using the string.find
function. (This is where our patterns come in handy)
function RichText.ParseText(Input: string): {Tag|Text}
local Tokens: {Tag|Text} = {}
while #Input > 0 do
local TagStart, TagEnd = string.find(Input, "<.->") -- Using the simple pattern to find tags.
-- If there is no tag, process the rest of the string
-- If there is a tag, and it is at the beginning, process it
-- If there is a tag, but it is not at the beginning, process up until the tag
local ProcessingTag = TagStart == 1
local ChunkEnd = ProcessingTag and TagEnd or TagStart and TagStart - 1 or #Input
local Chunk = string.sub(Input, 1, ChunkEnd) -- Separate the chunk
if ProcessingTag then -- We are processing a tag
table.insert(Tokens, RichText.ReadTag(Chunk) or {
Type = "TEXT",
Content = Chunk,
})
else -- We are processing text, just create a token with the content being the raw text
table.insert(Tokens, {
Type = "TEXT",
Content = Chunk,
})
end
Input = string.sub(Input, ChunkEnd + 1, -1) -- Remove the chunk we processed
end
return Tokens
end
And there we have it! That’s our ParseText
function done! (with one small caveat)
You may have noticed a function called ReadTag
being in there, and wondered “what is that?”. Well the answer is nothing! (yet)
The next step in our process is defining this function, which needs to take a raw string (that we know is a tag), process it, and return a table containing all the necessary information about the tag and its potential attributes. This is also where I would like to do some validation of the tag.
Now I have decided here that if a tag is invalid, I want to throw a warning in the console, and render the would-be-tag as normal text instead.
So let’s start by creating a boilerplate for this ReadTag
function!
In needs to take a string as an argument, and it needs a return-type of Tag?
, where if nothing is returned, we will treat that as the tag being invalid syntax.
This results in:
function RichText.ReadTag(Tag: string): Tag?
return
end
Pretty simple, huh?
Well, let’s get to implementing! We’ll take the same approach as before, where we walk through the process mentally, to get a better idea of what we need to do!
First, let’s validate the tag using the pattern we prepared earlier. Afterwards we can strip away the first and last character, as we know these are the wrapping less-than and greater-than signs. We then need to extract the tag name, which will be split by whitespaces. The rest of the string will be any present attributes. We can work our way through the attributes using the string.gmatch
function, and round up the results in a table.
Lastly we want to combine all the data we read from the string, into a properly formatted table, of the Tag
type, and return it.
After a bit of fiddling around (and a lot of pattern matching) we are left with this:
function RichText.ReadTag(Tag: string): Tag?
local ValidTag = string.match(Tag, "<%s*/?%a+(.-)%s*>")
if not ValidTag then -- No match found means the tag is invalid
warn(`Parsed Tag ({Tag}) could not be read.`)
return
end
while #ValidTag > 0 do
local Attribute = string.match(ValidTag, "^%s+%a+%s*=%s*\".-\"")
if not Attribute then -- Tag is invalid, syntax error with attributes
warn(`Parsed Tag ({Tag}) could not be read.`)
return
end
ValidTag = string.sub(ValidTag, #Attribute + 1, -1)
end
Tag = string.sub(Tag, 2, -2) -- Strip away the first and last character.
local NameChunk = string.match(Tag, "^%s*/?%a+%s*")::string -- We know this has to yield a result, otherwise the pattern above would have failed
local Opening = string.find(NameChunk, "/") == nil -- If there is no forward slash, it is an opening tag
local Name = string.gsub(NameChunk, "[%s/]", "") -- Removing all whitespace and forward slashes will leave only the name
local AttributesChunk = string.sub(Tag, #NameChunk + 1, -1) -- The rest of the tag is all the attributes
local Content = {} -- A dictionary containing all attributes
for Attribute in string.gmatch(AttributesChunk, "%a+%s*=%s*\".-\"") do -- This loops over each attribute by matching the pattern of an attribute repeatedly through gmatch
local Key, Value = table.unpack(string.split(Attribute, "="))
Value = string.sub(Value, 2, -2) -- Remove the quotes around the value
Content[Key] = Value -- Add attribute to content table
end
return { -- Build tag table and return it
Type = "TAG",
Name = Name,
Opening = Opening,
Content = Content,
}
end
To test this, let’s run the ParseText
function on some example text.
I’m going to use this input:
<test a="A" b="B">hello this is some content</test>and this is outside the tag
And it works!
With this, we can now parse our text, and we will be left with a table containing the tokens as we defined them.
Error handling
We could call it a day here in terms of parsing input, since we can now parse a string input, and be given the data necessary to build the UI. However I do also want to go a bit into some error handling. While we do validate tags using our pattern matching, there are a couple more points-of-error I want to cover!
Let’s create a function that validates the resulting array of tokens!
This function needs to take an array of tokens as an argument, and it should return a boolean value, that denotes whether or not the array is valid. This will allow us to add a couple of checks, and we can throw in some error messages to help us debug invalid input in the future!
Once again, let’s start by creating some boilerplate code.
function RichText.ValidateTokens(Tokens: {Tag|Text}): boolean
return true
end
Now let’s take a second to consider what validations we want to do.
A simple on to start with, let’s validate that each opening tag has a closing tag, and let’s also ensure that all tags are nested properly.
What do I mean by proper nesting?
We want to allow nesting, as that will allow us to combine various different tags, however all nested tags must be closed in the reverse order that they were opened.
For example, this is valid nesting:
<a>
<b>
hello
</b>
</a>
However this is not:
<a>
<b>
hello
</a>
</b>
We can validate both these two aspect at once, and this is one of the places where we bring in the stack data structure!
Let’s loop over each tag, adding them to the stack if they are opening tags, and if they are closing, the must ensure that the top tag in the stack is of the same name, before we then remove it. Then at the end, we verify that the stack is empty.
function RichText.ValidateTokens(Tokens: {Tag|Text}): boolean
local Tags = Stack.new()
for _, Token in Tokens do
if Token.Type ~= "TAG" then continue end -- We only care about tags for this validation
if Token.Opening then
Tags:Push(Token.Name) -- If it is an opening tag, push it to the stack
else
local Top = Tags:Pop() -- Remove tag from top of stack
if Top.Name ~= Token.Name then -- Tag name mismatch
warn(`Invalid nesting of Tags. Expected </{Top.Name}>, got </{Token.Name}>.`) -- Output expected and recieved name of tag for debugging
return false -- Invalidate tokens
end
end
end
if not Tags:Empty() then -- If not all tags were closed
for _, Tag: Tag in Tags do
warn(`Missing closing tag </{Tag.Name}>.`) -- Output their name for debugging
end
return false -- Invalidate tokens
end
return true
end
These are both pretty simple validations we can run. However they yield pretty good results. There is one more validation I wish to add right now, however feel free to implement any validations you can think of.
I also want to ensure that no closing tag has any attributes.
However I don’t intend to invalidate the array of tokens if any of the closing tags does have attributes, but instead I wish to output a warning. This is done in case a user of the module mistakenly apply attributes to a closing tag, however we don’t invalidate the tokens, as they can still render perfectly fine.
We can check if the closing tag has any attributes by checking if its Content
field is empty. Take note that since the Content
field has string keys, we cannot check its length. Instead we use the next
function.
This leaves us with this validation function (for now):
function RichText.ValidateTokens(Tokens: {Tag|Text}): boolean
local Tags = Stack.new()
for _, Token in Tokens do
if Token.Type ~= "TAG" then continue end -- We only care about tags for this validation
if Token.Opening then
Tags:Push(Token.Name) -- If it is an opening tag, push it to the stack
else
if next(Token.Content) ~= nil then
warn(`Warning, attributes defined for closing tag ({Token.Name}), please define attributes in opening tag instead.`)
end
local Top = Tags:Pop() -- Remove tag from top of stack
if Top ~= Token.Name then -- Tag name mismatch
warn(`Invalid nesting of Tags. Expected </{Top}>, got </{Token.Name}>.`) -- Output expected and recieved name of tag for debugging
return false -- Invalidate tokens
end
end
end
if not Tags:Empty() then -- If not all tags were closed
for _, Tag: Tag in Tags do
warn(`Missing closing tag </{Tag}>.`) -- Output their name for debugging
end
return false -- Invalidate tokens
end
return true -- No validation issues found
end
Building UI
Creating Labels
Before we get into creating the GuiObjects needed to render our rich text, let’s first consider how we want our module to function.
I want to be able to take any text container (TextLabel
, TextBox
, or TextButton
), pass it to the module, and our custom rich text should then be applied.
I also want to ensure that the text is the only part of the GuiObject that is affected, and that the properties of the text container still function.
Lastly, I want to be able to revert a text container that has been modified by our module, back to its original state.
To achieve this, we will create a function that takes either a TextLabel
, TextBox
, or TextButton
as an argument, and creates all the necessary UI. And we will create a second function that reverts the actions of the first function.
Let’s once again start with some boilerplate code.
function RichText.Enrich(TextContainer: TextLabel|TextBox|TextButton): ()
end
function RichText.Derich(TextContainer: TextLabel|TextBox|TextButton): ()
end
Now in order to make our rich text reversible, let’s simply create a folder inside the text container, in which we then create all future GuiObjects. With this approach, we can reverse the effect by simply destroying this folder. I will also store information, such as the raw text of the text container, as attributes of this folder.
After we have prepared this folder, we need to call the ParseText
function, while passing the text of the text container, the resulting tokens then need to be validated. Once we have validated our tokens, we can iterate over them in-order, and then create labels for the text as we go. This is again where the Stack data structure comes in handy. We can use a stack to keep track of what tags are currently active. We will update this stack as we iterate through the tokens, and each time we create a label, we can use the stack to determine which rich text effects need to be applied.
This ends up looking something a little like this:
function RichText.Enrich(TextContainer: TextContainer): ()
local RichTextContainer = Instance.new("Folder")
RichTextContainer.Name = "<RichTextContainer>" -- Name that is unlikely to be used by other systems
RichTextContainer.Parent = TextContainer
local Layout = Instance.new("UIListLayout")
Layout.FillDirection = Enum.FillDirection.Horizontal
Layout.Wraps = TextContainer.TextWrapped -- Wrap rich text if original label wrapped
Layout.ItemLineAlignment = Enum.ItemLineAlignment.End -- Align text of different sizes at their bottom
Layout.Parent = RichTextContainer
-- Type annotation complains if the type is still TextContainer
-- For our purposes in this function, this is fine
local TextContainer = TextContainer::TextLabel
local Text = TextContainer.Text
RichTextContainer:SetAttribute("Raw", Text)
TextContainer.Text = "" -- Clear out TextContainer text before we replace with rich text
local Tokens = RichText.ParseText(Text) -- Parse the text of the TextContainer
if not RichText.ValidateTokens(Tokens) then return end -- Validate the tokens and halt if invalid
local State = Stack.new()
for _, Token in Tokens do -- Iterate over all tokens
if Token.Type == "TAG" then -- Tags affect the state stack
if Token.Opening then
State:Push(Token)
else
State:Pop()
end
else -- Text is create using labels
BuildLabel(Token.Content, RichTextContainer, State)
end
end
end
function RichText.Derich(TextContainer: TextContainer): ()
local RichTextContainer: Instance? = TextContainer:FindFirstChild("<RichTextContainer>")
-- Type annotation complains if the type is still TextContainer
-- For our purposes in this function, this is fine
local TextContainer = TextContainer::TextLabel
if RichTextContainer then
TextContainer.Text = RichTextContainer:GetAttribute("Raw") or ""
RichTextContainer:Destroy()
end
end
Now once again, there’s a function here that is yet to be implemented.
We need to also implement the BuildLabel
function.
This function needs to create a TextLabel
, set all its properties appropriately, and then parent it to the RichTextContainer
folder, at which point the label is position automatically by the UIListLayout
.
Fortunately, the BuildLabel
function is pretty straight forward, albeit a bit tedious.
We can start off by creating a boilerplate.
local function BuildLabel(Text: string, RichTextContainer: Folder, State: Stack.Stack): ()
end
Here we actually need to make a few adjustments to the Enrich
function. Earlier I stated that I wanted to ensure that the properties of the text container are still functional when using the module. So we need to copy the properties that affect how text renders, so that the labels create by BuildLabel
can have the correct properties.
Taking a look at the documentation for TextLabel
s, TextBox
s, and TextButton
s, I have determined that the properties I want to carry over are:
- FontFace
- Font
- LineHeight
- TextColor3
- TextSize
- TextStrokeColor3
- TextStrokeTransparency
- TextTransparency
There are a couple more properties that should ideally be accounted for, however for simplicity, we will stick to these for now.
Let’s save all these properties using attributes, similarly to what we did with the raw text of the text container.
RichTextContainer:SetAttribute("FontFace", TextContainer.FontFace)
RichTextContainer:SetAttribute("LineHeight", TextContainer.LineHeight)
RichTextContainer:SetAttribute("TextColor3", TextContainer.TextColor3)
RichTextContainer:SetAttribute("TextSize", TextContainer.TextSize)
RichTextContainer:SetAttribute("TextStrokeColor3", TextContainer.TextStrokeColor3)
RichTextContainer:SetAttribute("TextStrokeTransparency", TextContainer.TextStrokeTransparency)
RichTextContainer:SetAttribute("TextTransparency", TextContainer.TextTransparency)
Now when we create our labels, we can set these properties to properly!
The last piece of the puzzle we need for the BuildLabel
function, is a way to determine the length of the label. However this is relatively trivial! We can make use of TextService:GetTextSize
.
So with that out of the way, let’s implement BuildLabel
.
local function BuildLabel(Text: string, RichTextContainer: Folder, State: Stack.Stack): ()
local Frame = Instance.new("Frame")
Frame.BackgroundTransparency = 1
local Label = Instance.new("TextLabel")
Label.BackgroundTransparency = 1 -- We want to show the background of the original label instead
Label.Text = Text
local FontSize: number = RichTextContainer:GetAttribute("TextSize")
local TextFont: Font = RichTextContainer:GetAttribute("Font")
Label.FontFace = RichTextContainer:GetAttribute("FontFace")
Label.LineHeight = RichTextContainer:GetAttribute("LineHeight")
Label.TextColor3 = RichTextContainer:GetAttribute("TextColor3")
Label.TextSize = FontSize
Label.TextStrokeColor3 = RichTextContainer:GetAttribute("TextStrokeColor3")
Label.TextStrokeTransparency = RichTextContainer:GetAttribute("TextStrokeTransparency")
Label.TextTransparency = RichTextContainer:GetAttribute("TextTransparency")
local Width = TextService:GetTextSize(Text, FontSize, TextFont, Vector2.one * math.huge).X
Frame .Size = UDim2.fromOffset(Width, FontSize)
Label.Size = UDim2.fromScale(1, 1)
Label.Parent = Frame
Frame.Parent = RichTextContainer
end
We’re almost there!
Implementing an example tag
Data structure
The last thing we need to do, is to take active tags into account. In order to do that, let’s create an example tag. For simplicity, I will create the bold tag.
First step is to figure out what a tag is (in terms of data).
For my purposes, I want tags to consist of:
- A name, which we use as the tag
- A function that takes a label and applies the desired effect
- A table containing all valid attributes for the tag
- A setting that specifies whether or not the tag needs to be applied on individual letter or not.
I will elaborate on that last one in a bit. First, let’s set up that example tag.
I’m going to create a folder inside my RichText module script, and title the folder “Tags”. I’m then gonna represent each tag by a module script inside this folder.
We can encode the name of the tag by simply naming the module script accordingly. Then we setup the module script to return a table, which contains the information we need.
return {
Letterwise = false,
Attributes = {},
Apply = function(Label: TextLabel, Attributes: {[string]: string}): {RBXScriptConnection}
return {}
end,
}
You might be wondering why the return value of the Apply
function is what it is.
I want some tags to apply effects that are updated in real time, such as color or position changes. In order to be able to clean up these tags once we call Derich
, I am going to be returning all connections made by the tag, so that I can disconnect them from the RichText module.
Before we fully implement this bold tag, let’s finish up the BuildLabel
function!
Wrapping up
We need to call the Apply
function of all active tags, passing the label being built. However to avoid issues, we don’t want to call the Apply
function of the same tag multiple times, even if there are nested tags.
A naïve way to solve this would be to only call the apply function for the most recent of each tag. However we will not do this. The reason being that if we have two active tags of the same type, but with different attributes set, we want to the Apply
function once, but with all the attributes of each copy of the tag cumulated.
We can achieve this by looping over the State stack, which loops from the bottom up, and then copying the attributes to a dictionary, with the tag name as the key.
local Attributes = {}
for _, Tag in State do
local Table = Attributes[Tag.Name] or {}
Attributes[Tag.Name] = Table
for Key, Value in Tag.Content do
Table[Key] = Value
end
end
However now we also need a way to access the table returned by the tag module, by the tag name. A simple approach to this is to require all the tag modules on startup, and storing them in a table with their name is the key.
Putting all this together, the BuildLabel
function becomes:
local function BuildLabel(Text: string, RichTextContainer: Folder, State: Stack.Stack): ()
local Frame = Instance.new("Frame")
Frame.BackgroundTransparency = 1
local Label = Instance.new("TextLabel")
Label.BackgroundTransparency = 1 -- We want to show the background of the original label instead
Label.Text = Text
local FontSize: number = RichTextContainer:GetAttribute("TextSize")
local TextFont: Font = RichTextContainer:GetAttribute("Font")
Label.FontFace = RichTextContainer:GetAttribute("FontFace")
Label.LineHeight = RichTextContainer:GetAttribute("LineHeight")
Label.TextColor3 = RichTextContainer:GetAttribute("TextColor3")
Label.TextSize = FontSize
Label.TextStrokeColor3 = RichTextContainer:GetAttribute("TextStrokeColor3")
Label.TextStrokeTransparency = RichTextContainer:GetAttribute("TextStrokeTransparency")
Label.TextTransparency = RichTextContainer:GetAttribute("TextTransparency")
local Width = TextService:GetTextSize(Text, FontSize, TextFont, Vector2.one * math.huge).X
Frame.Size = UDim2.fromOffset(Width, FontSize)
Label.Size = UDim2.fromScale(1, 1)
Label.Parent = Frame
Frame.Parent = RichTextContainer
local Attributes = {}
for _, Tag in State do
local Table = Attributes[Tag.Name] or {}
Attributes[Tag.Name] = Table
for Key, Value in Tag.Content do
Table[Key] = Value
end
end
for Tag, Table in Attributes do
Modules[Tag].Apply(Label, Table)
end
end
We’re almost ready to fully implement the tag.
Let’s first store all those connections that may be returned in a table, so that we can access them once we call Derich
.
At the top of the module I create a variable like so:
local Connections: {[Folder]: {RBXScriptConnection}} = {}
Then when I create the RichTextContainer folder in Enrich
, I use the folder as the key in this Connections
table, to point to another table.
Connections[RichTextContainer] = {}
Now back in the BuildLabel
function I copy all entries in the table returned by the Apply
function into this table created in the Enrich
function.
for Tag, Table in Attributes do
for _, Connection in Modules[Tag].Apply(Label, Table) do
table.insert(Connections[RichTextContainer], Connection)
end
end
Lastly, in the Derich
function, before destroying the RichTextContainer, I disconnect all connections.
for _, Connection in Connections[RichTextContainer] do
Connection:Disconnect()
end
table.clear(Connections[RichTextContainer])
This is good progress, but we still got two more adjustments on the agenda before we can wrap up the bold tag.
Next up I want to add another validation check, now that we have a table of valid attributes for each tag, as well as a way to check if a tag exists, I want to consider those in the ValidateTokens
function.
Once those checks are added, it becomes:
function RichText.ValidateTokens(Tokens: {Tag|Text}): boolean
local Tags = Stack.new()
for _, Token in Tokens do
if Token.Type ~= "TAG" then continue end -- We only care about tags for this validation
if not Modules[Token.Name] then -- Tag does not exist
warn(`Invalid token ({Token.Name}), tag does not exist.`)
return false -- Invalidate tokens
end
for Attribute, _ in Token.Content do
if not table.find(Modules[Token.Name].Attributes, Attribute) then -- Tag does not support attribute
warn(`Invalid attribute ({Attribute}) defined for tag ({Token.Name}).`)
return false -- Invalidate tokens
end
end
if Token.Opening then
Tags:Push(Token.Name) -- If it is an opening tag, push it to the stack
else
if next(Token.Content) ~= nil then
warn(`Warning, attributes defined for closing tag ({Token.Name}), please define attributes in opening tag instead.`)
end
local Top = Tags:Pop() -- Remove tag from top of stack
if Top ~= Token.Name then -- Tag name mismatch
warn(`Invalid nesting of Tags. Expected </{Top}>, got </{Token.Name}>.`) -- Output expected and recieved name of tag for debugging
return false -- Invalidate tokens
end
end
end
if not Tags:Empty() then -- If not all tags were closed
for _, Tag: Tag in Tags do
warn(`Missing closing tag </{Tag}>.`) -- Output their name for debugging
end
return false -- Invalidate tokens
end
return true -- No validation issues found
end
Now lastly, I want to keep track of whether or not any active tags require each letter to have their own label, and if so, create the labels accordingly.
This will be useful for certain tags like typewriter effects or text shake.
This is thankfully trivial to do, we simply keep a count of how many active tags require labels to be letterwise, and if there is at least one, we split text tokens’ content into individual letters.
local LetterwiseCount = 0
local State = Stack.new()
for _, Token in Tokens do -- Iterate over all tokens
if Token.Type == "TAG" then -- Tags affect the state stack
if Token.Opening then
State:Push(Token)
if Modules[Token.Name].Letterwise then
LetterwiseCount += 1
end
else
State:Pop()
if Modules[Token.Name].Letterwise then
LetterwiseCount -= 1
end
end
else -- Text is create using labels
if LetterwiseCount == 0 then
BuildLabel(Token.Content, RichTextContainer, State)
else
for _, Character in string.split(Token.Content, "") do
BuildLabel(Character, RichTextContainer, State)
end
end
end
end
With this, we can go back to the bold tag module, and finish implementing it.
The easiest way for us to implement bold text in your custom rich text module is, ironically, to use Roblox’ rich text.
return {
Letterwise = false,
Attributes = {},
Apply = function(Label: TextLabel, Attributes: {[string]: string}): {RBXScriptConnection}
Label.RichText = true
Label.Text = "<b>"..Label.Text.."</b>"
return {}
end,
}
And with that, we are done!
Although there are couple of changes I wish to make.
I want to set the LayoutOrder
of the Labels to be the number label it is in the RichTextContainer.
This can be done like so:
Label.LayoutOrder = #RichTextContainer:GetChildren()
This does not affect anything, however it allows tags to know what index the current label is.
This can also be achieved using attributes.
While this entire system is modular, I want to hardcode one feature. I want to allow line breaks to be denoted by <br>
, and I want those tags to not be included in the State
stack, they should also bypass validation, as I don’t want to have to create closing tags for line breaks.
This is a relatively trivial change, however I will leave this as an exercise for you, the reader. I will give the hint that line breaks can be easily created by inserting a frame with the size scale (1, 0) into the RichTextContainer (Although this only works if your text container wraps text).
Conclusion
Well, that’s that.
Hopefully you learned something, I know I had fun creating this system as I was writing this guide.
The system is pretty powerful, well, as powerful as you make it. You have to write your own rich text tags. There are some pretty neat effects you can make though. One of my personal favorites is text shake.
I’ll leave a game file here, in case anyone is just here for the final implementation, however I highly encourage you to go through it step by step, and making any changes you see fit. It’s a great learning experience.
The game file does include a text shake effect, as an example of tags that require letterwise labels and uses connections and attributes.
RichText.rbxl (60.7 KB)
And feel free to share any cool effects you implement here, I would love to see them!