Writing your own Rich Text system

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:

  1. Terminology
  2. Parsing Text
    • Defining Syntax
    • String patterns
    • Stack data structure
    • Implementation
    • Error handling
  3. Building UI
    • Creating Labels
  4. 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!
image

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 TextLabels, TextBoxs, and TextButtons, 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!

18 Likes

This reminds me of my text formatter tutorial but this one is clearly better. Good job!

Aw man, I really wish I had this tutorial 2 months ago when I started trying to rewrite defaultio’s module without a clue of how any of it worked :sweat_smile:, it’s been a rough ride.

I do have a couple questions if you don’t mind (forgive me if it’s in the post, it’s 2 am so I kinda just took a quick glance and can’t say I fully understood everything, i’ll have a proper read tomorrow):

  • Do you think you could add a preview video? I’ll be sure to check out the place file tomorrow but it’s 2am rn, i’d love to have a little preview of what your results were.
  • How would you calculate the line count and char size for the final rendered text? By the looks of it you didn’t handle multiple lines but I might have just missed it. Personally both GetTextSize and GetTextBounds gave me wrong results for scaled text, so I had to use a placeholder label and rely on it’s TextBounds by slowly substracting text from it (even this was not fully accurate, so I had to do a 2nd pass myself using the lines the previous gave me to rebalance the words across lines, and as you’ll see in the gif below the character size isn’t particularly accurate).
  • How would you handle scaling the previous to resolution and such? Line count tends to change with resolution for ScaledText and so does the distribution of the text, so i’m curious.

Sorry if any of the questions is silly, richText parsing/rendering isn’t exactly my strong point, but I needed this for a dialogue system.

Here’s my attempt and an example if anyone’s curious (i’d share the implementation but I haven’t managed to finish it, so idk if it’d be useful, and idk if you’re fine with me sharing it):

--Create marker
local Marker = SleekText.new(Frame, "<Sound = 421058925><font color=#FF7800><Sound = 154147007, Looped = true>Hi there!</Sound></font> <Animate style=wiggle>This is a dialogue 🐷!</Animate> And this is an image <Image=14280990902/>! And this is a bunch of filler text to make the thing longer.</Sound>")

--Completed
Marker.StateChanged:Connect(function(State)
	if State == "Completed" then
		print("Marker done playing")
	end
end)

--Access the marker's lines
for LineNum, Line in Marker.Lines do
	if LineNum == 1 then --Print the number of the line
		print("This is the first line.")
	end
	for _, Char in Line.Chars do --Access the line's chars
		if Char.Label:IsA("TextLabel") and Char.Label.ContentText == "H" then --Identify a specific letter
			print("This is the letter H.")
		end
	end
end

--Play
task.delay(5, function()
	Marker:Play()
end)
1 Like

Hey there! Sorry about the delayed response. I finished this guide pretty late into the night, so I went to be immediately after posting it.

Before I get to your questions, I would like to clarify, this guide, while it does provide an exact implementation, is more so supposed to give insight into how I approach problem solving and system design. This is the reason why I explain my thought process throughout the guide. This point here being, some features aren’t included in the attached implementation, but could be added with relative ease.

Now, to answer your questions:
The implementation attached in the game file does support multiple lines, however only if the text container is text wrapped. For simplicity in the guide, I elected to use a UIListLayout to layout all the labels, however if I were to implement multiple lines without text wrapping, I would get rid of this UIListLayout, and position each label manually instead, I would also have to check if labels need to be split into 2 labels on new lines, however this can be done using TextService:GetTextSize. This is a bit more tedious, but would be a good solution. With this approach it is also trivial to get the line count, since you would need to keep track of the line count regardless when positioning labels.

As for the character size. The height of the label should be the FontSize. Since FontSize is really just the pixel height of a line. As for the width, I use TextService:GetTextSize, this should always work, assuming you input the correct arguments. For scaled text, your issue is likely that you aren’t passing the correct font size to this function.

As for implementing support for changes in resolution. There might be a smarter solution, but for simplicity I would just clear and reapply the rich text whenever the absolute size of the text container changes. Although with this approach it would be smart to store somewhere when the rich text was originally applied, so you can sync up effects that happen over time.

As for a preview video, fair warning: I haven’t implemented that many different tags, as this is a guide for the overall system, rather than individual effects, but here is a bit of a showcase:

I used the following input:

<b>Bold Text</b><br>
Newline!<br>
<shake>Shaking text!</shake><br>
<shake xmag="0">This only shakes vertically</shake><br>
<shake ymag="0">This only shakes horizontally</shake><br>
<shake xmag="2" ymag="2">And this shakes a lot!</shake><br>
This text is <font color="#FF0000"><b>RED</b></font><br>
<shake><font color="#1155FF">This entire line shakes and is blue!</font></shake>

Which results in this:

I also like to use typewriter and fade-in effects, however I haven’t gotten around to making those yet this time around.

I hope this helps!

5 Likes

Might not be a big deal, but I noticed a bug(?) where text wrap only works on tokens and ignores labels, so let’s say we only want one word, at the start of the text to be styled differently, then we make everything else one long string, the string gets put into the next line and it goes off screen if it’s especially long!

Expected result:


Gotten result:

It’s not a problem for me personally as I know a few ways to fix it (still working on it, might post my solution later), but I thought I’d still throw it in here, in case you’d want to fix this or so that others would look into fixing it for themselves.

Edit:
This is the input text that I used:
Hello! This is a testing text! :pog_face: Also, I want to test Text Wrapping, since I don’t know if it works. Okay, so from what I see, it DOESN’T work! Oh no!

1 Like

Alrighty! I am back with the solution!

Firstly, we’ll need utility functions:

function fit_word_into_width(word : string, font_size : number, font : Font, width : number) : array
	local cutouts = {}
	
	local cutout_index = 1

	for i = 1,#word do
		local word_cutout = string.sub(word,cutout_index,i)
		local word_width = text_service:GetTextSize(word_cutout, font_size, font, Vector2.zero*math.huge).X

		if word_width > width then
			--We reached the point where further cutting will lead us out of frame again
			print(string.sub(word,cutout_index,i-1))
			table.insert(cutouts,string.sub(word,cutout_index,i-1))
			cutout_index = i

		elseif i >= #word then
			print(string.sub(word,cutout_index,i))
			table.insert(cutouts,string.sub(word,cutout_index,i))
		end
	end
	return cutouts
end

function split_with_keep(text : string, delimiter : string) : array
	local words = string.split(text,delimiter)
	local new_words = {}
	for i,word in pairs(words) do
		if i ~= #words then
			word = word .. delimiter
		end
		table.insert(new_words, word)
	end
	return new_words
end

Both of these functions will help us with wrapping text correctly later on.
I’ll try my best to lead you through the “BuildLabel” function solution as I possibly can, although please note I am not very skilled at explaining things, so I’ll do it by firstly, explaining how I did it and secondly, showing the code.

The first thing we’d wanna do for the SIMPLEST approach, is to check each word and make sure that they are aren’t out of bounds. We do it by using a while loop made to track how much of the text we went through:

while #text > 0 do
   local used_text = ""
end

we will use the used_text variable to track what text to put inside of our created label.
Next up, we want to then loop through the words and depending on their length, we either move them to a new line or keep them in the same one:

while #raw_text > 0 do
		local used_text = ""
		local text_width = 0
		
		local words = split_with_keep(raw_text, " ") --We split the words so that 1 array entry = 1 word

		for i, word in pairs(words) do
			local word_width = text_service:GetTextSize(word, font_size, text_font, Vector2.zero*math.huge).X
			if word_width <= text_length then
				--The word fits in the remaining space and isn't bigger than the container
				text_length -= word_width
				used_text = used_text .. word
				text_width += word_width
				
			else
				--We go to new line
				text_length = text_container.AbsoluteSize.X
				raw_text = string.sub(raw_text,#used_text+1,#raw_text)
				break
			end
		end
		
		if used_text == raw_text then
			raw_text = "" --This is mandatory for the script to not go into an infinite loop
		end
		
		-- >>> CONTINUE YOUR LABEL CREATIONS HERE!!! <<<
	end

And we can leave it at that if you don’t care about polish! Although this way won’t exactly be 100% accurate to the Default TextWrapped that you’d see on text objects. So let’s polish it up a bit so it works as good as roblox did it!
First things first, fixing the spaces! Since with our current system, the spaces are counted as letters, therefore in rare cases can lead to the text being wrapped early and having a 1 space gap between the beginning of the line and end of the line.
Here’s how well do that (all of this is done in the previously shown for loop).
Firstly, we want to keep track of the raw word (the one with those icky spaces) and the clean word (without any spaces) and we’ll do that by just saving it as a separate variable and we can even tackle the space at the beginning of lines in this example too!

local true_word = word

if text_length == text_container.AbsoluteSize.X and string.sub(true_word,1,1) == " " then
	true_word = string.sub(true_word,2,-1)
end

Now that we ensured we won’t have any icky spaces at the start of the line, we now need to make sure that those icky spaces won’t disturb our word wrapping! And we’ll do that by inserting a check once we’ve reached the end of the line and we are getting ready to move onto the next line:

--We double check to make sure that a space isn't marking the word as "out of bounds" and instead the actual letters are
if string.sub(word,#true_word,#true_word) == " " then
	true_word = string.sub(true_word,1,#true_word-1)
end
word_width = text_service:GetTextSize(true_word, font_size, text_font, Vector2.zero*math.huge).X
--If that's the case, we add that word to the line
if word_width <= text_length then
	used_text = used_text .. word
	text_width += word_width
end

--We go to new line
text_length = text_container.AbsoluteSize.X
raw_text = string.sub(raw_text,#used_text+1,#raw_text)
break

I’ll let you figure out where to place these snippets yourself as a learning exercise (or you can just look at the final script lol)
Alright! Looks like we are finished, right? We removed those icky spaces, we wrap words correctly. Life’s alright! But wait! What’s that? Oh no! It seems that super long words that don’t fit in the container are also break our WordWrapping and our entire label script!
Alright, so how do we fix that?
We cut up words that are too big too fit of course! (This is where our utility function comes in for fitting words). At this point, this is getting super complicated to explain, so I won’t show any more examples, only the simple step by step and the final script!

  1. We have to make a word memory for those pesky big words. This can be easily done by creating a new string variable BEFORE the while loop. (I named mine “word_memory”)
  2. Now, through our wrapping for loop, we add another if before the one that checks if the word fits and we turn the check for the word fitting into an elseif statement.
  3. If the word is truly too big, we store it in memory and we move onto the next line (just copy the line from the original text wrapping)
  4. Now that we have the memory, we set the words array to an empty one and before doing the for loop with the words, we check if we have anything in the memory if so, we set the words array to the return of fit_word_into_width()
  5. That’s pretty much it, just remember to clear memory! Since otherwise you’ll get an infinite loop.

So, finally… after going every single bug that may happen, we come to this beauty of a build_label script function!

function build_label(raw_text: string, taffy_container: Folder, text_container : {TextLabel | TextButton | TextBox}, taffy_stack: stack.Stack, length_left : number): number
	local font_size: number = taffy_container:GetAttribute("TextSize")
	local text_font: Font = taffy_container:GetAttribute("Font")

	local text_length = length_left
	local word_memory = "" --We will use this for over-sized words, so in case the word is too big to fit, we will store it in memory and use it next loop
	
	while #raw_text > 0 do
		local used_text = ""
		local text_width = 0
		
		local words = {}
		
		--If we have a word in memory (meaning it's too big for the container), we cut it up
		if word_memory ~= "" then
			words = fit_word_into_width(word_memory, font_size, text_font, text_container.AbsoluteSize.X)
			
			word_memory = ""
		else
			-- otherwise continue with default behaviour
			-- split_with_keep let's us split up words so that we can check if they fit and keeps the spaces, unlike default roblox behaviour
			words = split_with_keep(raw_text, " ")
		end
		
		for i, word in pairs(words) do
			--We make a "clone" of the word for the sole purpose of removing spaces, since we'll still need the spaces to render the text correctly
			local true_word = word
			
			--We check so that if the word is in a new line (meaning we have max text length) we remove any spaces at the front.
			if text_length == text_container.AbsoluteSize.X and string.sub(true_word,1,1) == " " then
				true_word = string.sub(true_word,2,-1)
			end
			
			local word_width = text_service:GetTextSize(true_word, font_size, text_font, Vector2.zero*math.huge).X
			
			if word_width >= text_container.AbsoluteSize.X then
				--When the word is bigger than the container, we give it a new line and cut it into smaller chunks, same as the default TextWrapped
				
				--We store the word in memory, which is used later to cut up the word into smaller chunks that fit the container
				word_memory = word
				
				--We go to new line
				text_length = text_container.AbsoluteSize.X
				raw_text = string.sub(raw_text,#used_text+1,#raw_text)
				break
			elseif word_width <= text_length then
				--The word fits in the remaining space and isn't bigger than the container
				text_length -= word_width
				used_text = used_text .. word
				text_width += word_width
				
			else
				--We double check to make sure that a space isn't marking the word as "out of bounds" and instead the actual letters are
				if string.sub(word,#true_word,#true_word) == " " then
					true_word = string.sub(true_word,1,#true_word-1)
				end
				
				word_width = text_service:GetTextSize(true_word, font_size, text_font, Vector2.zero*math.huge).X
				
				--If that's the case, we add that word to the line
				if word_width <= text_length then
					used_text = used_text .. word
					text_width += word_width
				end
				
				--We go to new line
				text_length = text_container.AbsoluteSize.X
				raw_text = string.sub(raw_text,#used_text+1,#raw_text)
				break
			end
		end
		
		if used_text == raw_text then
			raw_text = "" --This is mandatory, otherwise it will go into an infinite loop for whatever reason :p
		end

		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 = used_text

		label.FontFace = taffy_container:GetAttribute("FontFace")
		label.LineHeight = taffy_container:GetAttribute("LineHeight")
		label.TextColor3 = taffy_container:GetAttribute("TextColor3")
		label.TextSize = font_size
		label.TextStrokeColor3 = taffy_container:GetAttribute("TextStrokeColor3")
		label.TextStrokeTransparency = taffy_container:GetAttribute("TextStrokeTransparency")
		--Label.TextTransparency = taffy_container:GetAttribute("TextTransparency")


		frame.Size = UDim2.fromOffset(text_width, font_size)

		label.Size = UDim2.fromScale(1, 1)

		label.Parent = frame
		frame.Parent = taffy_container
	end
	
	--We return the offset of the remainder text so that we can keep track when making labels for other tokens
	return text_length - length_left
end

ALSO VERY IMPORTANT! LOOK AT THE NEWLY ADDED VARIABLES IN THE FUNCTION AS I FORGOT TO MENTION THEM!

And just one last little thing, we need to do.
Since you may have noticed the return part of the function, we have to use that return somewhere! And that somewhere is the for loop that builds the contents of our parsed text! I won’t explain much here, just read through the comments I added and you should be good to go!

--How many pixels we have left to fit the text in
	local length_left = text_container.AbsoluteSize.X
	
	for _, token in text_tokens do -- Iterate over all tokens
		if token.type_of == "Tag" then -- Tags affect the state stack
			if token.is_opening then
				taffy_stack:Push(token)
			else
				taffy_stack:Pop()
			end
		else -- Text is create using labels
			--With the way we return the length used, we are able to offset length_left without having to add any other additional wrap logic
			local length_used = build_label(token.content, taffy_container, text_container, taffy_stack, length_left)
			length_left += length_used
		end
	end
4 Likes

And also, as proof that this madness actually works!
The black outline is made to show the position of how the Default TextWrapped works and the white letters are my doing!

1 Like

Hey there, thank you for your contributions to this thread!
I wouldn’t necessarily call this a bug, since it is something I didn’t attempt to tackle whatsoever. However it is one of the features I would like to add!

I looked at your approach to addressing the wrapping issue, and it looks very good!
Seeing your work here got me fired up, so I went ahead and also implemented my own solution for this. (Which can be found here: RichText.rbxl (62.5 KB))

I took a very similar approach to yours, but with some slight tweaks, although they were mostly a matter of preference.

In the file attached, I made sure that the behavior of the rich text will depend on the properties of the text container it is applied to. So it supports text wrapping being on or off, as well as all combinations of text-x and text-y alignment, and lineheights.

I haven’t done a ton of testing for this, so there may be some issues I am unaware of.
I am however currently aware of the following issues:

  • LineHeight being set below 1 does nothing
  • Tags in the middle of large words will cause the word to split across into a new line, which sometimes isn’t necessary.

Fixing these is definitely possible, however they would require a bit more work to do.
The currently barrier for the lineheight is that I am using UIListLayouts to layout the labels, which doesn’t allow for a negative margin in any way (at least not AFAIK). So fixing the lineheight issue would require manually placing all labels. It’s not impossible, but might be a bit tedious.

As for the issue with tags being placed in the middle of large words. Frankly, this is a very small issue, as it only shows when you have a word longer than the entire width of the text container, and you have a tag in the middle of that word. To fix this, you would have to calculate the total length of the word while considering that the font size may change throughout the word (Meaning a single TextService:GetTextSize call may not be enough). It would also require some changes to how text is added as labels, since currently with my implementation, it does not consider the next token when figuring out how to fit the text into the container.

Once again, thank you for your contributions to this thread. They were very insightful.

2 Likes

Thought I might share what I’ve made so far, that being wiggle, shake and bounce effects for text!
Although I used a slightly different approach from yours. (Instead of each letter getting it’s own frame, I just split the letters inside of a single frame)!
These 3 effects are practically the only reason I’ve always made my own text systems, but I always used arrays and dictionaries without much optimization (each letter 1 new label and each letter updating it’s own stuff constantly, even when it doesn’t need to) so the fact that you showed a way to do it while keeping the default RichText formatting is genuinely amazing!

And yes, I did name my module “Taffy” as I like to give them cute names instead of the basic stuff like “RichTextModule” or “EnricherModule” :3

1 Like

Heyo, I’m trying to add a typewriter effect to the module rn, which is going relatively well aside from a little issue I’m encountering where strings with nested tags (if one of them is bold (or italic in my case, which is just the bold tag but < i> instead of < b>)) appear to be sized incorrectly (which I noticed is because apparently, instead of getting the width of R, E, and D, it gets the width of <i>R<\i>, <i>E<\i>, and <i>D<\i>, but even knowing that, I can’t really figure out how to fix it, as simply removing the lines that sets the frame’s size in my modified function doesn’t seem to impact anything)

Here’s my code for the function that handles typewriting (a modified version of BuildLabel):

local function BuildLabelTypewriter(Text: string, RichTextContainer: Folder, Parent: Frame, State: Stack.Stack, Speed: number, AlignmentX: "Left"|"Center"|"Right", AlignmentY: "Top"|"Center"|"Bottom"): ()
	local FontSize: number = RichTextContainer:GetAttribute("TextSize")
	local TextFont: Font = RichTextContainer:GetAttribute("Font")

	local index = 1
	local function TypeNextCharacter()
		if index <= #Text then
			local Character = string.sub(Text, index, index)

			local Frame = Instance.new("Frame")
			Frame.BackgroundTransparency = 1

			local Label = Instance.new("TextLabel")
			Label.BackgroundTransparency = 1
			Label.Text = Character
			Label.LayoutOrder = #Parent:GetChildren() - 1

			Label.TextXAlignment = Enum.TextXAlignment[AlignmentX]
			Label.TextYAlignment = Enum.TextYAlignment[AlignmentY]

			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 CharacterWidth = TextService:GetTextSize(Character, FontSize, TextFont, Vector2.one * math.huge).X
			
			Frame.Size = UDim2.fromOffset(CharacterWidth, FontSize)
			Label.Size = UDim2.fromScale(1, 1)

			Label.Parent = Frame
			Frame.Parent = Parent
			
			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
				for _, Connection in Modules[Tag].Apply(Label, Table) do
					table.insert(Connections[RichTextContainer], Connection)
				end
			end

			index += 1
			task.wait(Speed)
			TypeNextCharacter()
		end
	end

	TypeNextCharacter()
end

If anyone has any idea on how I could try to fix this, please tell me, i’ve been stuck on this for hours lol :pray:

1 Like

i love this tutorial, so i made some silly tests with it.

Sorry about the late response.
I’ll come back in a few hours and give this a proper read-through (That ended up being a lot more than a few hours :sweat_smile:).
In the meantime, I would recommend you implement a typewriter effect the same way you would implement other tags. If you use a single thread to build all the labels, you can simply add a task.wait call to the Apply function.

Edit:
I’m not entirely sure what the cause of your issue is. From what I can tell, you are getting the proper width of the label correctly. If you could attach a place-file demonstrating this issue, I could take a closer look.

I may be late, but how would you group the text inside tags for animations?