
This article details the implementation of a read-only text display control that supports automatic word wrapping, embedded color tags, hyperlink highlighting and clicking, mouse hover underlines, and a vertical scrollbar (with mouse wheel, click, drag, and page-by-page scrolling). The implementation is based on SDL2/SDL_ttf, with rendering and measurement interfaces unified by the sRender
wrapper. The control’s inheritance structure is UIControl
→ UIScrollView
→ UITextField
. Since horizontal scroll bars are rarely used in games, they are not implemented in this article.
Table of Contents
- Design Goals and Interface Overview
- UIScrollView: Scrollbar Geometry and Interaction
- UITextField: Text Layout Engine (Colors, Links, Wrapping)
- Rendering Clipping and Visible Area Calculation
- Event Handling: Wheel, Click, Drag, and Hover
- Detailed Analysis of Challenges and Pitfalls (with Formulas and Improvements)
- Performance and Maintainability Optimization
- Potential for Extension
- Complete Example and Key Code Snippets
- Conclusion
1. Design Goals and Interface Overview
1.1 Feature Checklist
- Automatic Line Wrapping: Soft wrapping based on pixel width (character-by-character for Chinese, word-by-word for English).
- Rich Text: Supports embedded color tags like
#R
,#G
,#B
, or#HEX
. - Link Syntax:
[LINK tag=... value=...]text[/LINK]
, supporting hover underlines and click callbacks. - Vertical Scrollbar:
- Fine-grained scrolling with the mouse wheel (line-by-line).
- Clicking up/down buttons, clicking the track for page scrolling, and dragging the thumb.
- Dynamic show/hide: Hides the scrollbar if content height is less than the view height, triggering a reflow when it appears or disappears.
- Clipped Rendering: Only draws visible lines to prevent over-drawing.
1.2 Key Public Interfaces
SetText(const std::string&)
: Sets the UTF-8 text.SetLinkClickedCallback(std::function<void(uint32_t,uint32_t)>)
: Link click callback (with integer versions of tag/value parameters).
2. UIScrollView: Scrollbar Geometry and Interaction
UIScrollView
is responsible for mapping the visible window to the content’s total height and provides a unified scrolling interaction. UITextField
only needs to inform it of the total number of lines and content height.
2.1 Scrollbar Structure Diagram

2.2 Data Structure
C++
class UIScrollView : public UIControl {
enum { UP, DOWN, BAR, BK }; // Up button, down button, thumb, track
// ...
int m_viewheight{0}; // Visible track height (excluding buttons)
int m_contentheight{0}; // Total content height
int m_scrollOffset{0}; // Current scroll offset in pixels
int m_maxScrollOffset{0}; // = contentHeight - viewHeightTotal
int m_thumbTrackHeight{0};
int m_thumbHeight{0};
int m_thumbPosition{0};
SDL_Rect m_rects[4]{}; // 4 regions: UP, DOWN, BAR, BK
bool m_scrollVisiable{false};
bool m_dragenable{false};
int m_dragStart{0};
};
2.3 Size Calculation (Core Logic)
- Total View Height:
viewHeightTotal = controlHeight
. - Visible Track Height:
m_viewheight = controlHeight - 2 * BAR_SIZE
(excluding buttons). - Content Height:
m_contentheight = lineCount * (lineHeight + lineSpace)
. - Max Scroll Offset:
m_maxScrollOffset = max(0, m_contentheight - viewHeightTotal)
. - Thumb Height:
- Theoretical:
thumbHeight = (viewHeightTotal / contentHeight) * m_viewheight
. - Actual: Must
clamp(thumbHeight, MIN_THUMB, m_viewheight)
, and handlecontentHeight == 0
.
- Theoretical:
- Thumb Track Height:
m_thumbTrackHeight = m_viewheight - m_thumbHeight
(can be 0). - Offset ↔ Thumb Position:
thumbPos = (scrollOffset / maxScrollOffset) * m_thumbTrackHeight
.scrollOffset = (thumbPos / m_thumbTrackHeight) * maxScrollOffset
.
Note: Extensive zero-division checks and clamping are necessary to prevent abnormal thumb jitter or positioning errors caused by NaN/infinity.
2.4 Hit Testing and Interaction
- Clicking UP/DOWN: Scrolls line-by-line via
OnScrollUp/Down(step)
. - Clicking BK (Track): Page-by-page scrolling. The step can be set to 5-10 lines.
- Dragging BAR (Thumb): Updates
m_thumbPosition
based on mouseΔy
and then calculatesm_scrollOffset
in reverse. - Mouse Wheel: A positive wheel delta scrolls up, a negative one scrolls down. (On Windows,
wheel.y > 0
means scrolling up).
3. UITextField: Text Layout Engine
Text layout (parsing → segmentation → measurement → wrapping → positioning) is the most challenging part of this control. We break the text down into a vector of lines, where each line consists of multiple segments. Each segment has its own color and link status.
3.1 Line Segment Structure
C++
struct TLine {
SDL_Color c{0,0,0,255};
std::wstring txt; // Text for this segment
bool isLink{false};
std::wstring tag, value; // Link metadata
SDL_Rect rect{}; // Segment's rectangle in screen coordinates
bool hover{false}; // Mouse hover state
};
// m_items: std::vector<std::vector<TLine>>; // Lines → Segments
3.2 Color Tag Parsing
- Shortcodes:
#R
,#G
,#B
,#C
,#K
,#W
,#Y
,#O
,#P
,#F
,#A
,#D
mapped to specificSDL_Color
s. - HEX:
#RRGGBB
, e.g.,#FF0000
for red. - Parsing Strategy: When encountering a
#
, first try to parse it as HEX. If that fails, try a shortcode. If both fail, treat it as a regular character.
3.3 Link Syntax Parsing
- Header:
[LINK tag=123 value=xxx]
. Use regexLINKs+tag=(d+)s+value=([^]]+)
to extract parameters. - Content: Text between
...[/LINK]
is recorded as a clickable segment. Its color can be set to green by default. - Note: Don’t render immediately during parsing. First, measure the width and apply the wrapping logic.
3.4 Automatic Line Wrapping (Soft Wrapping)
- Effective Line Width:
lineWidth = clientWidth - padding - (hasScroll? scrollbarWidth : 0)
. - Character/Word Traversal: Accumulate
currentWidth
. If it exceedslineWidth
:- Flush the accumulated
buffer
as a segment. - Move the current character/word to the next line and reset
currentWidth
. - Update the vertical position:
y += lineHeight + lineSpace
.
- Flush the accumulated
- A link segment is measured as a whole. If it doesn’t fit on a line, the entire segment is moved to the next line.
Measurement Interface: sRender->GetTextSize(wtext, fontSize)
and sRender->GetTextWidths(wtext,fontSize)
(for a per-character width array).
4. Rendering Clipping and Visible Area Calculation
Use SDL_RenderSetClipRect(renderer, rcClient)
to clip the drawing area. Calculate the index of the first visible line based on m_scrollOffset
:
C++
const int stride = m_lineheight + m_linespace;
const int startItem = (m_scrollOffset % stride) ? (m_scrollOffset / stride + 1) : (m_scrollOffset / stride);
const int visibleRows = m_size.y / stride;
int endItem = std::min<int>(startItem + visibleRows, (int)m_items.size());
Only render lines within the [startItem, endItem)
range. Call DrawString()
for each segment. If segment.hover && isLink
, draw an underline to enhance feedback.
After rendering, restore the global clipping rectangle.
5. Event Handling: Wheel, Click, Drag, and Hover
- Mouse Wheel:
SDL_MOUSEWHEEL
event triggersOnScrollUp/Down(3)
(a step of 3 lines is a good default). - Click:
- Iterate through each segment in
m_items
. IfSDL_PointInRect(pt, &segment.rect)
hits a link, trigger the callback:m_linkclickedfun(_wtoi(seg.tag.c_str()), _wtoi(seg.value.c_str()))
.
- Iterate through each segment in
- Hover:
- During
SDL_MOUSEMOTION
, only update thehover
state of link segments. - Optional: Call
SDL_SetCursor
to change the cursor to a hand.
- During
- Thumb Dragging: Handled by
UIScrollView
.UITextField
does not need to be aware of this.
6. Detailed Analysis of Challenges and Pitfalls
6.1 Scrollbar Math (Avoiding Division by Zero and Misalignment)
Problem: When contentHeight <= viewHeightTotal
, m_maxScrollOffset
becomes 0
. If it’s still used in thumbPos = (offset / maxOffset) * track
, a division-by-zero error occurs. Solution:
C++
m_maxScrollOffset = std::max(0, m_contentheight - sz.y);
if (m_maxScrollOffset == 0) {
m_thumbHeight = m_viewheight;
m_thumbTrackHeight = 0;
m_thumbPosition = 0;
}
Similarly, protect the reverse calculation:
C++
if (m_thumbTrackHeight > 0)
m_scrollOffset = int((float)m_thumbPosition / m_thumbTrackHeight * m_maxScrollOffset);
else
m_scrollOffset = 0;
6.2 More Robust Thumb Height Formula
The current implementation uses m_thumbHeight = m_viewheight * 1.0f * m_size.y / m_contentheight;
. It is better to replace it with:
C++
m_thumbHeight = int((float)sz.y / m_contentheight * m_viewheight);
const int MIN_THUMB = 20; // For better feel
m_thumbHeight = std::clamp(m_thumbHeight, MIN_THUMB, m_viewheight);
6.3 First Line Index Calculation Boundary
The calculation for startItem
must align with the rendering’s y
coordinate to avoid skipping or duplicating lines. A recommended unified approach is:
C++
const int stride = m_lineheight + m_linespace;
const int startItem = m_scrollOffset / stride;
const int yStart = rcClient.y + (m_lineheight/2) - (m_scrollOffset % stride);
Then, use yStart
as the starting point for drawing the first line and accumulate row by row. This avoids the unintuitive +1
check.
6.4 Measurement Caching and Reflow Timing
- Minimize regex and measurement calls within
ResolveText()
. - When the scrollbar’s visibility changes, the line width changes, so a reflow is necessary. Calling
ResolveText()
fromInnerScrollVisiableChanged()
is correct. - However, avoid repeated recursive refreshes. The internal logic should ensure that a reflow is triggered only once after the dimensions stabilize.
6.5 Link Segment Rectangle Coordinates
The current implementation uses local coordinates (relative to the top-left of the control). If rendering and hit testing use global coordinates, they must remain consistent:
- Before rendering: Convert the local
destRect
to a global one based onGetGlobalRect()
. - Hit testing: If the mouse position is in global coordinates,
segment.rect
should also be in global coordinates.
6.6 Text and Color State Synchronization
When a color tag is encountered at the end of a line where a line wrap occurs, you must flush the old segment before switching colors to prevent the color from “bleeding” into the new line. The current implementation already follows this principle.
6.7 Multilingual and Monospaced Assumptions
The GetTextWidths()
per-character measurement already avoids assumptions about character widths. However, right-to-left (RTL) text like Arabic or Hebrew is not yet handled and can be a future extension.
6.8 DPI/Scaling
If DPI scaling is introduced, values like BAR_SIZE
, MIN_THUMB
, and lineSpace
should be uniformly scaled to maintain a consistent feel for user interaction.
7. Potential for Extension
- Selection and Copy: Add a cursor, selection area, and
Ctrl+C
. - More Rich Text Tags:
[B]bold[/B]
,[I]italic[/I]
,[IMG]
for embedded images, etc. - Word Wrapping: Prioritize breaking lines at spaces for English, and at characters for Chinese.
- Keyboard Navigation:
PageUp
/PageDown
/Home
/End
keys. - Editable Mode: Extend the read-only control into a text input field (requires IME, blinking cursor, delete/insert, etc.).
8. Complete Example and Key Code Snippets
The following code snippets are organized from your implementation and include some robustness enhancements and comments, making them easy to reuse or compare. For brevity, only the key differences are shown; the rest can remain consistent with the existing implementation.
8.1 UIScrollView: Enhanced Core Calculations
C++
void UIScrollView::CalcScrollSize(bool cnt2bar) {
Vec2 sz = GetSize();
const int cx = sz.x;
const int cy = sz.y;
m_viewheight = cy - 2 * _BAR_SIZE;
m_contentheight = m_linecount * (m_lineheight + m_linespace);
// Defensive check: handle empty content
if (m_contentheight <= 0) {
m_scrollVisiable = m_alwaysShowScrollbar; // Optional: Force show
m_maxScrollOffset = 0;
m_thumbHeight = m_viewheight;
m_thumbTrackHeight = 0;
m_thumbPosition = 0;
auto r = GetGlobalRect();
m_rects[0] = { r.x + r.w - _BAR_SIZE, r.y, _BAR_SIZE, _BAR_SIZE };
m_rects[1] = { r.x + r.w - _BAR_SIZE, r.y + r.h - _BAR_SIZE, _BAR_SIZE, _BAR_SIZE };
m_rects[2] = { r.x + r.w - _BAR_SIZE, r.y + _BAR_SIZE, _BAR_SIZE, m_viewheight };
m_rects[3] = { r.x + r.w - _BAR_SIZE, r.y, _BAR_SIZE, r.h };
return;
}
// Max offset
m_maxScrollOffset = std::max(0, m_contentheight - cy);
// Thumb height and track height
if (m_viewheight > 0 && m_contentheight > 0) {
int thumb = int((float)cy / m_contentheight * m_viewheight);
const int MIN_THUMB = 20;
m_thumbHeight = std::clamp(thumb, MIN_THUMB, m_viewheight);
} else {
m_thumbHeight = m_viewheight;
}
m_thumbTrackHeight = std::max(0, m_viewheight - m_thumbHeight);
// Offset ↔ Thumb position mapping
if (cnt2bar) {
if (m_maxScrollOffset > 0 && m_thumbTrackHeight > 0) {
m_thumbPosition = int((float)m_scrollOffset / m_maxScrollOffset * m_thumbTrackHeight);
} else {
m_thumbPosition = 0;
}
} else {
if (m_thumbTrackHeight > 0 && m_maxScrollOffset > 0) {
m_scrollOffset = int((float)m_thumbPosition / m_thumbTrackHeight * m_maxScrollOffset);
} else {
m_scrollOffset = 0;
}
}
m_scrollOffset = std::clamp(m_scrollOffset, 0, m_maxScrollOffset);
auto r = GetGlobalRect();
m_rects[UP] = { r.x + r.w - _BAR_SIZE, r.y, _BAR_SIZE, _BAR_SIZE };
m_rects[DOWN] = { r.x + r.w - _BAR_SIZE, r.y + r.h - _BAR_SIZE, _BAR_SIZE, _BAR_SIZE };
m_rects[BAR] = { r.x + r.w - _BAR_SIZE, r.y + _BAR_SIZE + m_thumbPosition, _BAR_SIZE, m_thumbHeight };
m_rects[BK] = { r.x + r.w - _BAR_SIZE, r.y, _BAR_SIZE, r.h };
}
8.2 UITextField: More Robust Visible Area and Drawing Start Point
C++
void UITextField::Render() {
__super::Render();
if (m_textsize.size() <= 1) return;
const SDL_Rect rcClient = GetGlobalRect();
SDL_RenderSetClipRect(sRender->GetRender(), &rcClient);
const int stride = m_lineheight + m_linespace;
const int startItem = m_scrollOffset / stride;
const int visibleRows = m_size.y / stride + 2; // +2 for a safe buffer
const int endItem = std::min<int>((int)m_items.size(), startItem + visibleRows);
int y = rcClient.y + (m_lineheight/2) - (m_scrollOffset % stride);
for (int i = startItem; i < endItem; ++i) {
int x = rcClient.x + 5;
for (auto &seg : m_items[i]) {
auto [ww, wh] = sRender->GetTextSize(seg.txt.c_str(), m_fontSize);
SDL_Rect dest = { x, y, ww, wh };
seg.rect = dest; // Global coordinates for hit testing
sRender->DrawString(seg.txt.c_str(), m_fontSize, dest, seg.c);
if (seg.hover && seg.isLink) {
sRender->DrawLine(x, y + wh, x + ww, y + wh, Color::GREEN);
}
x += ww;
}
y += stride;
}
SDL_Rect rcWnd = sRender->GetClientRect();
SDL_RenderSetClipRect(sRender->GetRender(), &rcWnd);
}
8.3 Text Reflow Triggers
C++
void UITextField::InnerScrollVisiableChanged(bool visiable) {
// Scrollbar visibility affects line width, so reflow is needed
ResolveText();
}
void UITextField::OnMove(int x, int y) {
__super::OnMove(x, y);
SetContentHeight((int)m_items.size());
}
void UITextField::OnTextChanged(const std::string& oldText) {
ResolveText();
}
8.4 Usage Example
C++
auto txt = std::make_shared<UITextField>();
txt->SetPosition({120,20});
txt->SetSize({200,75});
txt->SetText("#RIn previous articles, #Gwe've implemented common #Blong string");
txt->SetLinkClickedCallback([](uint32_t tag, uint32_t value){
// Handle link clicks here
});
AddNode(txt);
Conclusion
This article has provided a bottom-up implementation of a scrollable rich text display control. From scrollbar geometry and visible line clipping to text parsing and link interaction, we’ve broken down the key implementation points and provided enhanced code snippets. Based on this, you can continue to expand capabilities like selection/copy, more rich text tags, word-by-word wrapping, and keyboard navigation, making UITextField
a reusable foundational control for your projects.
Source Code for this Tutorial: https://github.com/84378996/simple_sdl_gui_tutorials/tree/main/tutorials08
Final Result Demo: