From 1892ff3c0db23ccdf7b0f6dc83cb1bdf4579b4ec Mon Sep 17 00:00:00 2001
From: SmallJoker <SmallJoker@users.noreply.github.com>
Date: Wed, 22 Jan 2020 19:09:11 +0100
Subject: [PATCH] StaticText/EnrichedString: Styling support (#9187)

* StaticText/EnrichedString: Styling support

* Fix tooltip fg/bgcolor

* Fix default color for substr(), add unittests
---
 games/minimal/mods/test/formspec.lua |  16 +-
 src/client/gameui.cpp                |   4 +-
 src/gui/guiButton.cpp                |   1 -
 src/gui/guiFormSpecMenu.cpp          |  15 +-
 src/irrlicht_changes/static_text.cpp | 253 +++++++++++----------------
 src/irrlicht_changes/static_text.h   |  17 +-
 src/unittest/test_utilities.cpp      |  20 +++
 src/util/enriched_string.cpp         |  71 ++++++--
 src/util/enriched_string.h           |  28 ++-
 9 files changed, 222 insertions(+), 203 deletions(-)

diff --git a/games/minimal/mods/test/formspec.lua b/games/minimal/mods/test/formspec.lua
index 64b9ec0d5..bac82c965 100644
--- a/games/minimal/mods/test/formspec.lua
+++ b/games/minimal/mods/test/formspec.lua
@@ -1,3 +1,5 @@
+local color = minetest.colorize
+
 local clip_fs = [[
 	style_type[label;noclip=%c]
 	style_type[button;noclip=%c]
@@ -31,8 +33,8 @@ local style_fs = [[
 		bgcolor_pressed=purple]
 	button[0,0;2.5,0.8;one_btn1;Button]
 
-	style[one_btn2;border=false;textcolor=cyan]
-	button[0,1.05;2.5,0.8;one_btn2;Text Button]
+	style[one_btn2;border=false;textcolor=cyan] ]]..
+	"button[0,1.05;2.5,0.8;one_btn2;Text " .. color("#FF0", "Yellow") .. [[]
 
 	style[one_btn3;bgimg=bubble.png;bgimg_hovered=default_apple.png;
 		bgimg_pressed=heart.png]
@@ -144,16 +146,18 @@ local pages = {
 		list[current_player;main;6,8;3,2;1]
 		button[9,0;2.5,1;name;]
 		button[9,1;2.5,1;name;]
-		button[9,2;2.5,1;name;]
-		label[9,0;This is a label.\nLine\nLine\nLine\nEnd]
-		button[9,3;1,1;name;]
+		button[9,2;2.5,1;name;] ]]..
+		"label[9,0.5;This is a label.\nLine\nLine\nLine\nEnd]"..
+		[[button[9,3;1,1;name;]
 		vertlabel[9,4;VERT]
 		label[10,3;HORIZ]
 		tabheader[6.5,0;6,0.65;name;Tab 1,Tab 2,Tab 3,Secrets;1;false;false]
 	]],
 
 		"size[12,12]real_coordinates[true]" ..
-		"label[0.375,0.375;Styled]" ..
+		("label[0.375,0.375;Styled - %s %s]"):format(
+			color("#F00", "red text"),
+			color("#77FF00CC", "green text")) ..
 		"label[6.375,0.375;Unstyled]" ..
 		"box[0,0.75;12,0.1;#999]" ..
 		"box[6,0.85;0.1,11.15;#999]" ..
diff --git a/src/client/gameui.cpp b/src/client/gameui.cpp
index 674d07fa6..3c7ed54b2 100644
--- a/src/client/gameui.cpp
+++ b/src/client/gameui.cpp
@@ -155,7 +155,7 @@ void GameUI::update(const RunStats &stats, Client *client, MapDrawControl *draw_
 
 	m_guitext2->setVisible(m_flags.show_debug);
 
-	setStaticText(m_guitext_info, translate_string(m_infotext).c_str());
+	setStaticText(m_guitext_info, m_infotext.c_str());
 	m_guitext_info->setVisible(m_flags.show_hud && g_menumgr.menuCount() == 0);
 
 	static const float statustext_time_max = 1.5f;
@@ -169,7 +169,7 @@ void GameUI::update(const RunStats &stats, Client *client, MapDrawControl *draw_
 		}
 	}
 
-	setStaticText(m_guitext_status, translate_string(m_statustext).c_str());
+	setStaticText(m_guitext_status, m_statustext.c_str());
 	m_guitext_status->setVisible(!m_statustext.empty());
 
 	if (!m_statustext.empty()) {
diff --git a/src/gui/guiButton.cpp b/src/gui/guiButton.cpp
index ed79999cf..f7a0af2d9 100644
--- a/src/gui/guiButton.cpp
+++ b/src/gui/guiButton.cpp
@@ -53,7 +53,6 @@ GUIButton::GUIButton(IGUIEnvironment* environment, IGUIElement* parent,
 			core::clamp<u32>(Colors[i].getGreen() * COLOR_PRESSED_MOD, 0, 255),
 			core::clamp<u32>(Colors[i].getBlue() * COLOR_PRESSED_MOD, 0, 255));
 	}
-	
 	StaticText = gui::StaticText::add(Environment, Text.c_str(), core::rect<s32>(0,0,rectangle.getWidth(),rectangle.getHeight()), false, false, this, id);
 	StaticText->setTextAlignment(EGUIA_CENTER, EGUIA_CENTER);
 	// END PATCH
diff --git a/src/gui/guiFormSpecMenu.cpp b/src/gui/guiFormSpecMenu.cpp
index a91623f96..d03ce4516 100644
--- a/src/gui/guiFormSpecMenu.cpp
+++ b/src/gui/guiFormSpecMenu.cpp
@@ -3417,19 +3417,16 @@ void GUIFormSpecMenu::drawMenu()
 void GUIFormSpecMenu::showTooltip(const std::wstring &text,
 	const irr::video::SColor &color, const irr::video::SColor &bgcolor)
 {
-	const std::wstring ntext = translate_string(text);
-	m_tooltip_element->setOverrideColor(color);
-	m_tooltip_element->setBackgroundColor(bgcolor);
-	setStaticText(m_tooltip_element, ntext.c_str());
+	EnrichedString ntext(text);
+	ntext.setDefaultColor(color);
+	ntext.setBackground(bgcolor);
+
+	setStaticText(m_tooltip_element, ntext);
 
 	// Tooltip size and offset
 	s32 tooltip_width = m_tooltip_element->getTextWidth() + m_btn_height;
-#if (IRRLICHT_VERSION_MAJOR <= 1 && IRRLICHT_VERSION_MINOR <= 8 && IRRLICHT_VERSION_REVISION < 2) || USE_FREETYPE == 1
-	std::vector<std::wstring> text_rows = str_split(ntext, L'\n');
-	s32 tooltip_height = m_tooltip_element->getTextHeight() * text_rows.size() + 5;
-#else
 	s32 tooltip_height = m_tooltip_element->getTextHeight() + 5;
-#endif
+
 	v2u32 screenSize = Environment->getVideoDriver()->getScreenSize();
 	int tooltip_offset_x = m_btn_height;
 	int tooltip_offset_y = m_btn_height;
diff --git a/src/irrlicht_changes/static_text.cpp b/src/irrlicht_changes/static_text.cpp
index 1375f033c..39b34d17c 100644
--- a/src/irrlicht_changes/static_text.cpp
+++ b/src/irrlicht_changes/static_text.cpp
@@ -32,21 +32,15 @@ StaticText::StaticText(const EnrichedString &text, bool border,
 			bool background)
 : IGUIStaticText(environment, parent, id, rectangle),
 	HAlign(EGUIA_UPPERLEFT), VAlign(EGUIA_UPPERLEFT),
-	Border(border), OverrideColorEnabled(false), OverrideBGColorEnabled(false), WordWrap(false), Background(background),
+	Border(border), WordWrap(false), Background(background),
 	RestrainTextInside(true), RightToLeft(false),
-	OverrideColor(video::SColor(101,255,255,255)), BGColor(video::SColor(101,210,210,210)),
 	OverrideFont(0), LastBreakFont(0)
 {
 	#ifdef _DEBUG
 	setDebugName("StaticText");
 	#endif
 
-	Text = text.c_str();
-	cText = text;
-	if (environment && environment->getSkin())
-	{
-		BGColor = environment->getSkin()->getColor(gui::EGDC_3D_FACE);
-	}
+	setText(text);
 }
 
 
@@ -73,12 +67,7 @@ void StaticText::draw()
 	// draw background
 
 	if (Background)
-	{
-		if ( !OverrideBGColorEnabled )	// skin-colors can change
-			BGColor = skin->getColor(gui::EGDC_3D_FACE);
-
-		driver->draw2DRectangle(BGColor, frameRect, &AbsoluteClippingRect);
-	}
+		driver->draw2DRectangle(getBackgroundColor(), frameRect, &AbsoluteClippingRect);
 
 	// draw the border
 
@@ -89,97 +78,60 @@ void StaticText::draw()
 	}
 
 	// draw the text
-	if (cText.size())
-	{
-		IGUIFont* font = getActiveFont();
-
-		if (font)
+	IGUIFont *font = getActiveFont();
+	if (font && BrokenText.size()) {
+		if (font != LastBreakFont)
+			updateText();
+
+		core::rect<s32> r = frameRect;
+		s32 height_line = font->getDimension(L"A").Height + font->getKerningHeight();
+		s32 height_total = height_line * BrokenText.size();
+		if (VAlign == EGUIA_CENTER && WordWrap)
 		{
-			if (!WordWrap)
-			{
-				// TODO: add colors here
-				if (VAlign == EGUIA_LOWERRIGHT)
-				{
-					frameRect.UpperLeftCorner.Y = frameRect.LowerRightCorner.Y -
-						font->getDimension(L"A").Height - font->getKerningHeight();
-				}
-				if (HAlign == EGUIA_LOWERRIGHT)
-				{
-					frameRect.UpperLeftCorner.X = frameRect.LowerRightCorner.X -
-						font->getDimension(cText.c_str()).Width;
-				}
+			r.UpperLeftCorner.Y = r.getCenter().Y - (height_total / 2);
+		}
+		else if (VAlign == EGUIA_LOWERRIGHT)
+		{
+			r.UpperLeftCorner.Y = r.LowerRightCorner.Y - height_total;
+		}
+		if (HAlign == EGUIA_LOWERRIGHT)
+		{
+			r.UpperLeftCorner.X = r.LowerRightCorner.X -
+				getTextWidth();
+		}
 
-#if USE_FREETYPE
-				if (font->getType() == irr::gui::EGFT_CUSTOM) {
-					irr::gui::CGUITTFont *tmp = static_cast<irr::gui::CGUITTFont*>(font);
-					tmp->draw(Text, frameRect,
-						OverrideColorEnabled ? OverrideColor :
-							skin->getColor(isEnabled() ? EGDC_BUTTON_TEXT : EGDC_GRAY_TEXT),
-						HAlign == EGUIA_CENTER, VAlign == EGUIA_CENTER,
-						(RestrainTextInside ? &AbsoluteClippingRect : NULL));
-				} else
-#endif
-				{
-					font->draw(Text.c_str(), frameRect,
-						skin->getColor(EGDC_BUTTON_TEXT),
-						HAlign == EGUIA_CENTER, VAlign == EGUIA_CENTER,
-						(RestrainTextInside ? &AbsoluteClippingRect : NULL));
-				}
-			}
-			else
+		irr::video::SColor previous_color(255, 255, 255, 255);
+		for (const EnrichedString &str : BrokenText) {
+			if (HAlign == EGUIA_LOWERRIGHT)
 			{
-				if (font != LastBreakFont)
-					breakText();
-
-				core::rect<s32> r = frameRect;
-				s32 height = font->getDimension(L"A").Height + font->getKerningHeight();
-				s32 totalHeight = height * BrokenText.size();
-				if (VAlign == EGUIA_CENTER)
-				{
-					r.UpperLeftCorner.Y = r.getCenter().Y - (totalHeight / 2);
-				}
-				else if (VAlign == EGUIA_LOWERRIGHT)
-				{
-					r.UpperLeftCorner.Y = r.LowerRightCorner.Y - totalHeight;
-				}
-
-				irr::video::SColor previous_color(255, 255, 255, 255);
-				for (u32 i=0; i<BrokenText.size(); ++i)
-				{
-					if (HAlign == EGUIA_LOWERRIGHT)
-					{
-						r.UpperLeftCorner.X = frameRect.LowerRightCorner.X -
-							font->getDimension(BrokenText[i].c_str()).Width;
-					}
-
-					EnrichedString str = BrokenText[i];
+				r.UpperLeftCorner.X = frameRect.LowerRightCorner.X -
+					font->getDimension(str.c_str()).Width;
+			}
 
-					//str = colorizeText(BrokenText[i].c_str(), colors, previous_color);
-					//if (!colors.empty())
-					//	previous_color = colors[colors.size() - 1];
+			//str = colorizeText(BrokenText[i].c_str(), colors, previous_color);
+			//if (!colors.empty())
+			//	previous_color = colors[colors.size() - 1];
 
 #if USE_FREETYPE
-					if (font->getType() == irr::gui::EGFT_CUSTOM) {
-						irr::gui::CGUITTFont *tmp = static_cast<irr::gui::CGUITTFont*>(font);
-						tmp->draw(str,
-							r, previous_color, // FIXME
-							HAlign == EGUIA_CENTER, false,
-							(RestrainTextInside ? &AbsoluteClippingRect : NULL));
-					} else
+			if (font->getType() == irr::gui::EGFT_CUSTOM) {
+				irr::gui::CGUITTFont *tmp = static_cast<irr::gui::CGUITTFont*>(font);
+				tmp->draw(str,
+					r, previous_color, // FIXME
+					HAlign == EGUIA_CENTER, VAlign == EGUIA_CENTER,
+					(RestrainTextInside ? &AbsoluteClippingRect : NULL));
+			} else
 #endif
-					{
-						// Draw non-colored text
-						font->draw(str.c_str(),
-							r, skin->getColor(EGDC_BUTTON_TEXT),
-							HAlign == EGUIA_CENTER, false,
-							(RestrainTextInside ? &AbsoluteClippingRect : NULL));
-					}
+			{
+				// Draw non-colored text
+				font->draw(str.c_str(),
+					r, str.getDefaultColor(), // TODO: Implement colorization
+					HAlign == EGUIA_CENTER, VAlign == EGUIA_CENTER,
+					(RestrainTextInside ? &AbsoluteClippingRect : NULL));
+			}
 
 
-					r.LowerRightCorner.Y += height;
-					r.UpperLeftCorner.Y += height;
-				}
-			}
+			r.LowerRightCorner.Y += height_line;
+			r.UpperLeftCorner.Y += height_line;
 		}
 	}
 
@@ -201,7 +153,7 @@ void StaticText::setOverrideFont(IGUIFont* font)
 	if (OverrideFont)
 		OverrideFont->grab();
 
-	breakText();
+	updateText();
 }
 
 //! Gets the override font (if any)
@@ -224,16 +176,15 @@ IGUIFont* StaticText::getActiveFont() const
 //! Sets another color for the text.
 void StaticText::setOverrideColor(video::SColor color)
 {
-	OverrideColor = color;
-	OverrideColorEnabled = true;
+	ColoredText.setDefaultColor(color);
+	updateText();
 }
 
 
 //! Sets another color for the text.
 void StaticText::setBackgroundColor(video::SColor color)
 {
-	BGColor = color;
-	OverrideBGColorEnabled = true;
+	ColoredText.setBackground(color);
 	Background = true;
 }
 
@@ -248,7 +199,10 @@ void StaticText::setDrawBackground(bool draw)
 //! Gets the background color
 video::SColor StaticText::getBackgroundColor() const
 {
-	return BGColor;
+	IGUISkin *skin = Environment->getSkin();
+
+	return (ColoredText.hasBackground() || !skin) ?
+		ColoredText.getBackground() : skin->getColor(gui::EGDC_3D_FACE);
 }
 
 
@@ -298,7 +252,7 @@ const video::SColor& StaticText::getOverrideColor() const
 video::SColor StaticText::getOverrideColor() const
 #endif
 {
-	return OverrideColor;
+	return ColoredText.getDefaultColor();
 }
 
 
@@ -306,13 +260,13 @@ video::SColor StaticText::getOverrideColor() const
 //! color in the gui skin.
 void StaticText::enableOverrideColor(bool enable)
 {
-	OverrideColorEnabled = enable;
+	// TODO
 }
 
 
 bool StaticText::isOverrideColorEnabled() const
 {
-	return OverrideColorEnabled;
+	return true;
 }
 
 
@@ -321,7 +275,7 @@ bool StaticText::isOverrideColorEnabled() const
 void StaticText::setWordWrap(bool enable)
 {
 	WordWrap = enable;
-	breakText();
+	updateText();
 }
 
 
@@ -336,7 +290,7 @@ void StaticText::setRightToLeft(bool rtl)
 	if (RightToLeft != rtl)
 	{
 		RightToLeft = rtl;
-		breakText();
+		updateText();
 	}
 }
 
@@ -348,12 +302,22 @@ bool StaticText::isRightToLeft() const
 
 
 //! Breaks the single text line.
-void StaticText::breakText()
+// Updates the font colors
+void StaticText::updateText()
 {
-	if (!WordWrap)
+	const EnrichedString &cText = ColoredText;
+	BrokenText.clear();
+
+	if (cText.hasBackground()) {
+		setBackgroundColor(cText.getBackground());
+	}
+
+	if (!WordWrap) {
+		BrokenText.push_back(cText);
 		return;
+	}
 
-	BrokenText.clear();
+	// Update word wrap
 
 	IGUISkin* skin = Environment->getSkin();
 	IGUIFont* font = getActiveFont();
@@ -574,25 +538,20 @@ void StaticText::breakText()
 //! Sets the new caption of this element.
 void StaticText::setText(const wchar_t* text)
 {
-	setText(EnrichedString(text));
+	setText(EnrichedString(text, getOverrideColor()));
 }
 
-//! Sets the new caption of this element.
 void StaticText::setText(const EnrichedString &text)
 {
-	IGUIElement::setText(text.c_str());
-	cText = text;
-	if (text.hasBackground()) {
-		setBackgroundColor(text.getBackground());
-	}
-	breakText();
+	ColoredText = text;
+	IGUIElement::setText(ColoredText.c_str());
+	updateText();
 }
 
-
 void StaticText::updateAbsolutePosition()
 {
 	IGUIElement::updateAbsolutePosition();
-	breakText();
+	updateText();
 }
 
 
@@ -603,39 +562,31 @@ s32 StaticText::getTextHeight() const
 	if (!font)
 		return 0;
 
-	s32 height = font->getDimension(L"A").Height + font->getKerningHeight();
-
-	if (WordWrap)
-		height *= BrokenText.size();
-
-	return height;
+	if (WordWrap) {
+		s32 height = font->getDimension(L"A").Height + font->getKerningHeight();
+		return height * BrokenText.size();
+	}
+	// There may be intentional new lines without WordWrap
+	return font->getDimension(BrokenText[0].c_str()).Height;
 }
 
 
 s32 StaticText::getTextWidth() const
 {
-	IGUIFont * font = getActiveFont();
-	if(!font)
+	IGUIFont *font = getActiveFont();
+	if (!font)
 		return 0;
 
-	if(WordWrap)
-	{
-		s32 widest = 0;
+	s32 widest = 0;
 
-		for(u32 line = 0; line < BrokenText.size(); ++line)
-		{
-			s32 width = font->getDimension(BrokenText[line].c_str()).Width;
-
-			if(width > widest)
-				widest = width;
-		}
+	for (const EnrichedString &line : BrokenText) {
+		s32 width = font->getDimension(line.c_str()).Width;
 
-		return widest;
-	}
-	else
-	{
-		return font->getDimension(cText.c_str()).Width;
+		if (width > widest)
+			widest = width;
 	}
+
+	return widest;
 }
 
 
@@ -647,14 +598,14 @@ void StaticText::serializeAttributes(io::IAttributes* out, io::SAttributeReadWri
 	IGUIStaticText::serializeAttributes(out,options);
 
 	out->addBool	("Border",              Border);
-	out->addBool	("OverrideColorEnabled",OverrideColorEnabled);
-	out->addBool	("OverrideBGColorEnabled",OverrideBGColorEnabled);
+	out->addBool	("OverrideColorEnabled",true);
+	out->addBool	("OverrideBGColorEnabled",ColoredText.hasBackground());
 	out->addBool	("WordWrap",		WordWrap);
 	out->addBool	("Background",          Background);
 	out->addBool	("RightToLeft",         RightToLeft);
 	out->addBool	("RestrainTextInside",  RestrainTextInside);
-	out->addColor	("OverrideColor",       OverrideColor);
-	out->addColor	("BGColor",       	BGColor);
+	out->addColor	("OverrideColor",       ColoredText.getDefaultColor());
+	out->addColor	("BGColor",       	ColoredText.getBackground());
 	out->addEnum	("HTextAlign",          HAlign, GUIAlignmentNames);
 	out->addEnum	("VTextAlign",          VAlign, GUIAlignmentNames);
 
@@ -668,14 +619,14 @@ void StaticText::deserializeAttributes(io::IAttributes* in, io::SAttributeReadWr
 	IGUIStaticText::deserializeAttributes(in,options);
 
 	Border = in->getAttributeAsBool("Border");
-	enableOverrideColor(in->getAttributeAsBool("OverrideColorEnabled"));
-	OverrideBGColorEnabled = in->getAttributeAsBool("OverrideBGColorEnabled");
 	setWordWrap(in->getAttributeAsBool("WordWrap"));
 	Background = in->getAttributeAsBool("Background");
 	RightToLeft = in->getAttributeAsBool("RightToLeft");
 	RestrainTextInside = in->getAttributeAsBool("RestrainTextInside");
-	OverrideColor = in->getAttributeAsColor("OverrideColor");
-	BGColor = in->getAttributeAsColor("BGColor");
+	if (in->getAttributeAsBool("OverrideColorEnabled"))
+		ColoredText.setDefaultColor(in->getAttributeAsColor("OverrideColor"));
+	if (in->getAttributeAsBool("OverrideBGColorEnabled"))
+		ColoredText.setBackground(in->getAttributeAsColor("BGColor"));
 
 	setTextAlignment( (EGUI_ALIGNMENT) in->getAttributeAsEnumeration("HTextAlign", GUIAlignmentNames),
                       (EGUI_ALIGNMENT) in->getAttributeAsEnumeration("VTextAlign", GUIAlignmentNames));
diff --git a/src/irrlicht_changes/static_text.h b/src/irrlicht_changes/static_text.h
index 43c587284..1f111ea56 100644
--- a/src/irrlicht_changes/static_text.h
+++ b/src/irrlicht_changes/static_text.h
@@ -34,7 +34,8 @@ namespace gui
 	{
 	public:
 
-		//! constructor
+		// StaticText is translated by EnrichedString.
+		// No need to use translate_string()
 		StaticText(const EnrichedString &text, bool border, IGUIEnvironment* environment,
 			IGUIElement* parent, s32 id, const core::rect<s32>& rectangle,
 			bool background = false);
@@ -201,23 +202,20 @@ namespace gui
 	private:
 
 		//! Breaks the single text line.
-		void breakText();
+		void updateText();
 
 		EGUI_ALIGNMENT HAlign, VAlign;
 		bool Border;
-		bool OverrideColorEnabled;
-		bool OverrideBGColorEnabled;
 		bool WordWrap;
 		bool Background;
 		bool RestrainTextInside;
 		bool RightToLeft;
 
-		video::SColor OverrideColor, BGColor;
 		gui::IGUIFont* OverrideFont;
 		gui::IGUIFont* LastBreakFont; // stored because: if skin changes, line break must be recalculated.
 
-		EnrichedString cText;
-		core::array< EnrichedString > BrokenText;
+		EnrichedString ColoredText;
+		std::vector<EnrichedString> BrokenText;
 	};
 
 
@@ -274,10 +272,7 @@ inline void setStaticText(irr::gui::IGUIStaticText *static_text, const EnrichedS
 
 inline void setStaticText(irr::gui::IGUIStaticText *static_text, const wchar_t *text)
 {
-	auto color = static_text->isOverrideColorEnabled()
-				     ? static_text->getOverrideColor()
-				     : irr::video::SColor(255, 255, 255, 255);
-	setStaticText(static_text, EnrichedString(text, color));
+	setStaticText(static_text, EnrichedString(text, static_text->getOverrideColor()));
 }
 
 #endif // _IRR_COMPILE_WITH_GUI_
diff --git a/src/unittest/test_utilities.cpp b/src/unittest/test_utilities.cpp
index 8e8958d18..447b591e1 100644
--- a/src/unittest/test_utilities.cpp
+++ b/src/unittest/test_utilities.cpp
@@ -20,6 +20,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
 #include "test.h"
 
 #include <cmath>
+#include "util/enriched_string.h"
 #include "util/numeric.h"
 #include "util/string.h"
 
@@ -49,6 +50,7 @@ public:
 	void testUTF8();
 	void testRemoveEscapes();
 	void testWrapRows();
+	void testEnrichedString();
 	void testIsNumber();
 	void testIsPowerOfTwo();
 	void testMyround();
@@ -79,6 +81,7 @@ void TestUtilities::runTests(IGameDef *gamedef)
 	TEST(testUTF8);
 	TEST(testRemoveEscapes);
 	TEST(testWrapRows);
+	TEST(testEnrichedString);
 	TEST(testIsNumber);
 	TEST(testIsPowerOfTwo);
 	TEST(testMyround);
@@ -344,6 +347,23 @@ void TestUtilities::testWrapRows()
 	}
 }
 
+void TestUtilities::testEnrichedString()
+{
+	EnrichedString str(L"Test bar");
+	irr::video::SColor color(0xFF, 0, 0, 0xFF);
+
+	UASSERT(str.substr(1, 3).getString() == L"est");
+	str += L" BUZZ";
+	UASSERT(str.substr(9, std::string::npos).getString() == L"BUZZ");
+	str.setDefaultColor(color); // Blue foreground
+	UASSERT(str.getColors()[5] == color);
+	// Green background, then white and yellow text
+	str = L"\x1b(b@#0F0)Regular \x1b(c@#FF0)yellow";
+	UASSERT(str.getColors()[2] == 0xFFFFFFFF);
+	str.setDefaultColor(color); // Blue foreground
+	UASSERT(str.getColors()[13] == 0xFFFFFF00); // Still yellow text
+	UASSERT(str.getBackground() == 0xFF00FF00); // Green background
+}
 
 void TestUtilities::testIsNumber()
 {
diff --git a/src/util/enriched_string.cpp b/src/util/enriched_string.cpp
index 642188a52..d5f8aa661 100644
--- a/src/util/enriched_string.cpp
+++ b/src/util/enriched_string.cpp
@@ -45,15 +45,27 @@ EnrichedString::EnrichedString(const wchar_t *str, const SColor &color)
 	addAtEnd(translate_string(std::wstring(str)), color);
 }
 
+void EnrichedString::clear()
+{
+	m_string.clear();
+	m_colors.clear();
+	m_has_background = false;
+	m_default_length = 0;
+	m_default_color = irr::video::SColor(255, 255, 255, 255);
+}
+
 void EnrichedString::operator=(const wchar_t *str)
 {
 	clear();
-	addAtEnd(translate_string(std::wstring(str)), SColor(255, 255, 255, 255));
+	addAtEnd(translate_string(std::wstring(str)), m_default_color);
 }
 
 void EnrichedString::addAtEnd(const std::wstring &s, const SColor &initial_color)
 {
 	SColor color(initial_color);
+	bool use_default = (m_default_length == m_string.size() &&
+		color == m_default_color);
+
 	size_t i = 0;
 	while (i < s.length()) {
 		if (s[i] != L'\x1b') {
@@ -90,6 +102,12 @@ void EnrichedString::addAtEnd(const std::wstring &s, const SColor &initial_color
 				continue;
 			}
 			parseColorString(wide_to_utf8(parts[1]), color, true);
+
+			// No longer use default color after first escape
+			if (use_default) {
+				m_default_length = m_string.size();
+				use_default = false;
+			}
 		} else if (parts[0] == L"b") {
 			if (parts.size() < 2) {
 				continue;
@@ -98,6 +116,10 @@ void EnrichedString::addAtEnd(const std::wstring &s, const SColor &initial_color
 			m_has_background = true;
 		}
 	}
+
+	// Update if no escape character was found
+	if (use_default)
+		m_default_length = m_string.size();
 }
 
 void EnrichedString::addChar(const EnrichedString &source, size_t i)
@@ -110,7 +132,7 @@ void EnrichedString::addCharNoColor(wchar_t c)
 {
 	m_string += c;
 	if (m_colors.empty()) {
-		m_colors.emplace_back(255, 255, 255, 255);
+		m_colors.emplace_back(m_default_color);
 	} else {
 		m_colors.push_back(m_colors[m_colors.size() - 1]);
 	}
@@ -118,35 +140,40 @@ void EnrichedString::addCharNoColor(wchar_t c)
 
 EnrichedString EnrichedString::operator+(const EnrichedString &other) const
 {
-	std::vector<SColor> result;
-	result.insert(result.end(), m_colors.begin(), m_colors.end());
-	result.insert(result.end(), other.m_colors.begin(), other.m_colors.end());
-	return EnrichedString(m_string + other.m_string, result);
+	EnrichedString result = *this;
+	result += other;
+	return result;
 }
 
 void EnrichedString::operator+=(const EnrichedString &other)
 {
+	bool update_default_color = m_default_length == m_string.size();
+
 	m_string += other.m_string;
 	m_colors.insert(m_colors.end(), other.m_colors.begin(), other.m_colors.end());
+
+	if (update_default_color) {
+		m_default_length += other.m_default_length;
+		updateDefaultColor();
+	}
 }
 
 EnrichedString EnrichedString::substr(size_t pos, size_t len) const
 {
-	if (pos == m_string.length()) {
+	if (pos >= m_string.length())
 		return EnrichedString();
-	}
-	if (len == std::string::npos || pos + len > m_string.length()) {
-		return EnrichedString(
-			m_string.substr(pos, std::string::npos),
-			std::vector<SColor>(m_colors.begin() + pos, m_colors.end())
-		);
-	}
 
-	return EnrichedString(
+	if (len == std::string::npos || pos + len > m_string.length())
+		len = m_string.length() - pos;
+
+	EnrichedString str(
 		m_string.substr(pos, len),
 		std::vector<SColor>(m_colors.begin() + pos, m_colors.begin() + pos + len)
 	);
-
+	if (pos < m_default_length)
+		str.m_default_length = m_default_length - pos;
+	str.setDefaultColor(m_default_color);
+	return str;
 }
 
 const wchar_t *EnrichedString::c_str() const
@@ -163,3 +190,15 @@ const std::wstring &EnrichedString::getString() const
 {
 	return m_string;
 }
+
+void EnrichedString::setDefaultColor(const irr::video::SColor &color)
+{
+	m_default_color = color;
+	updateDefaultColor();
+}
+
+void EnrichedString::updateDefaultColor()
+{
+	for (size_t i = 0; i < m_default_length; ++i)
+		m_colors[i] = m_default_color;
+}
diff --git a/src/util/enriched_string.h b/src/util/enriched_string.h
index 202d84cb0..eaab3bd91 100644
--- a/src/util/enriched_string.h
+++ b/src/util/enriched_string.h
@@ -32,6 +32,7 @@ public:
 		const irr::video::SColor &color = irr::video::SColor(255, 255, 255, 255));
 	EnrichedString(const std::wstring &string,
 		const std::vector<irr::video::SColor> &colors);
+	void clear();
 	void operator=(const wchar_t *str);
 	void addAtEnd(const std::wstring &s, const irr::video::SColor &color);
 
@@ -50,6 +51,14 @@ public:
 	const wchar_t *c_str() const;
 	const std::vector<irr::video::SColor> &getColors() const;
 	const std::wstring &getString() const;
+
+	void setDefaultColor(const irr::video::SColor &color);
+	void updateDefaultColor();
+	inline const irr::video::SColor &getDefaultColor() const
+	{
+		return m_default_color;
+	}
+
 	inline bool operator==(const EnrichedString &other) const
 	{
 		return (m_string == other.m_string && m_colors == other.m_colors);
@@ -58,12 +67,6 @@ public:
 	{
 		return !(*this == other);
 	}
-	inline void clear()
-	{
-		m_string.clear();
-		m_colors.clear();
-		m_has_background = false;
-	}
 	inline bool empty() const
 	{
 		return m_string.empty();
@@ -72,6 +75,7 @@ public:
 	{
 		return m_string.size();
 	}
+
 	inline bool hasBackground() const
 	{
 		return m_has_background;
@@ -80,9 +84,19 @@ public:
 	{
 		return m_background;
 	}
+	inline void setBackground(const irr::video::SColor &color)
+	{
+		m_background = color;
+		m_has_background = true;
+	}
+
 private:
 	std::wstring m_string;
 	std::vector<irr::video::SColor> m_colors;
-	bool m_has_background = false;
+	bool m_has_background;
+	irr::video::SColor m_default_color;
 	irr::video::SColor m_background;
+	// This variable defines the length of the default-colored text.
+	// Change this to a std::vector if an "end coloring" tag is wanted.
+	size_t m_default_length;
 };
-- 
2.25.1