Step-by-Step Guide to Basic SDL UI Controls: UITextField (with Scrollbar and Hyperlinks)

sdl-ui-control-scrollbar_textfield

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 UIControlUIScrollViewUITextField. 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 handle contentHeight == 0.
  • 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 calculates m_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 specific SDL_Colors.
  • 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 regex LINKs+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 exceeds lineWidth:
    • 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.
  • 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 triggers OnScrollUp/Down(3) (a step of 3 lines is a good default).
  • Click:
    • Iterate through each segment in m_items. If SDL_PointInRect(pt, &segment.rect) hits a link, trigger the callback: m_linkclickedfun(_wtoi(seg.tag.c_str()), _wtoi(seg.value.c_str())).
  • Hover:
    • During SDL_MOUSEMOTION, only update the hover state of link segments.
    • Optional: Call SDL_SetCursor to change the cursor to a hand.
  • 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() from InnerScrollVisiableChanged() 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 on GetGlobalRect().
  • 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:

Leave a Reply