UI Utils

38 min read

The runtime toolkit you reach for when building debug overlays, combat logs, and HUD widgets in code: fluent builders for TMP (TextMeshPro) labels and input fields, momentum and line-stepped scroll views, anchor and spacing value types, and a one-call debug canvas bootstrap.

Summary

The Scylla UI utilities are the runtime toolkit for building TMP (TextMeshPro)-backed widgets in code: labels, input fields, and scrollable text logs, plus the canvas, anchor, and measurement code those widgets sit on. Where the UI Layout utilities answer “how do these boxes arrange themselves”, this set answers “what goes in the boxes and how is it built”. The two are complementary; a typical HUD uses the layout stacks to position panels and these utilities to fill them.

Almost everything here exists because hand-authoring a TMP hierarchy in the Inspector is tedious and easy to get subtly wrong. A TMP_InputField alone needs a root with a background image, a clipped text-area child, a placeholder label, and an input label – all anchored correctly – before a single character can be typed. The fluent builders (UITextBuilder, UITextFieldBuilder, ScrollTextAreaBuilder, LineScrollTextAreaBuilder) collapse that into a readable chain of calls that produces a fully wired widget on a UI-layered GameObject. Shared presets (TMPSettings) keep typography consistent across a project, and a small TMP-free abstraction layer (IUIText, IUITextField, UITextFactory) lets gameplay code drive text without importing TextMeshPro at every call site.

The headline pieces are the two scrolling text views. ScrollTextArea is a momentum scroller with iOS-style flick, deceleration, and elastic edge bounce – the right shape for a combat log or chat panel that the player can dragswith the mouse. LineScrollTextArea is its line-discrete sibling: it steps a fixed window of lines up and down with key-repeat timing, which is exactly how a terminal or an in-game debug console wants to behave under Page Up and Page Down. Both keep a bounded ring buffer of lines, both support text selection and clipboard copy, and both publish optional events through the Scylla event system. The Scylla Console module is built on top of them.

Use the UI utilities for:

  • In-game debug consoles, CLIs (Command Line Interfaces), and command lines that need a scrollback log plus a text-entry field, wired up in code rather than authored as a prefab.
  • Combat logs, chat panels, quest-journal feeds, and any other widget that streams lines of text and lets the player scroll back through history.
  • HUD labels, score readouts, timers, and live debug overlays built from a fluent chain instead of a hand-anchored TMP object.
  • Name-entry boxes, seed-entry fields, and cheat prompts that need character filtering, content-type validation, and submit callbacks.
  • Bootstrapping a screen-space debug canvas and event system from nothing, so an overlay can render before any UI prefab exists in the scene.
  • Measuring how tall a block of text will be before it is placed, for tooltips, dialogue boxes, and speech bubbles that size themselves to their content.

Features

  • Fluent text and field builders. UITextBuilder and UITextFieldBuilder construct a configured TextMeshProUGUI label or a full TMP_InputField hierarchy from a readable method chain, assign the result to the UI layer, and hand back the live component. No Inspector wiring, no forgotten placeholder child.
  • Momentum and line-stepped scroll views. ScrollTextArea gives continuous flick-and-decelerate scrolling with elastic edge bounce; LineScrollTextArea steps a fixed window of lines with key-repeat timing. Both back onto a bounded ring buffer so a chat log that runs for an hour never grows without limit.
  • Shared typography presets. TMPSettings carries a full TMP configuration as a value bag, with named presets (Header, Body, Caption, Button, Console, InputField) and a clone-then-customise workflow so one heading style is defined once and reused everywhere.
  • TMP-free abstraction layer. IUIText and IUITextField expose text, styling, and events without leaking the TMP namespace into call sites, and UITextFactory produces them, which keeps gameplay and tooling code decoupled from the rendering backend.
  • Text selection and clipboard. Both scroll views support click-drag selection, select-all, and copy-to-clipboard, so a player can lift a stack trace or a chat line straight out of the log.
  • Zero-allocation text updates. IUIText.SetCharArray writes from a char buffer with no managed string allocation, the right path for a per-frame timer, score counter, or live debug readout that would otherwise churn the garbage collector.
  • Anchor, alignment, margin, and padding value types. UIAnchor and UIAnchors capture the nine standard anchor presets plus stretch configurations; UIAlignment, UIMargin, and UIPadding express placement and spacing as small reusable values rather than raw RectTransform math.
  • Canvas and event-system bootstrapping. DebugCanvasUtil spins up a screen-space overlay canvas with a high sort order and a working event system (Input System or legacy, resolved at runtime) so a debug overlay can exist with zero scene setup.
  • DPI-aware helpers and TMP plumbing. UIUtil covers DPI (Dots Per Inch) scaling, UI-layer assignment, and screen-to-local point conversion; TMPUtil covers component creation, configuration, and accurate text measurement against TMP’s own layout engine.
  • Optional event publishing. Scroll position changes, line additions, and drag start/end raise strongly-typed events through the Scylla event system (SEX, the Scylla Event eXchange) when enabled, so a “jump to bottom” button or an unread-message badge can react without polling.
  • Gradient panel backings. GradientBackground builds a two-color vertical gradient from a tiny 2×2 texture and stretches it behind any panel, a cheap way to give a HUD element depth without a custom shader.

Building blocks

The toolkit is a set of fluent builders, a couple of TMP-free interfaces and their wrappers, two scrolling widget components, and the value types and static helpers they all lean on. A few terms recur below. TMP is TextMeshPro, Unity’s SDF (Signed Distance Field) text renderer, which Scylla uses for all text. A raycast target is a graphic that intercepts pointer events; display-only labels usually turn this off so clicks pass through to whatever is behind them. Momentum scrolling is the flick-and-coast behavior familiar from touch devices, where the content keeps moving after the finger lifts and decelerates to rest. A ring buffer is a fixed-capacity line store that overwrites its oldest entry once full, which bounds memory for a log that never stops appending. A measurement component is a hidden TMP object used only to ask “how tall would this text be” without disturbing anything on screen.

PieceKindNotes
UITextBuilderBuilderFluent construction of a TextMeshProUGUI label: text, font, size, color, alignment, overflow, wrap, auto-size, spacing, anchor, and position. Terminal Build returns the component; BuildAbstract returns it as IUIText. The first reach for any code-built label.
UITextFieldBuilderBuilderFluent construction of a complete TMP_InputField widget (background, clipped text area, placeholder, input label) with content type, line mode, character limit, colors, and the four TMP edit callbacks. BuildAbstract returns IUITextField.
ScrollTextAreaBuilderBuilderBuilds a ScrollTextArea: font, buffer capacity, momentum physics, drag, scrollbar style, selection, optional background, and event publishing. The configuration surface for the momentum log.
LineScrollTextAreaBuilderBuilderBuilds a LineScrollTextArea: font, buffer capacity, key-repeat scroll timing, edge bounce, scrollbar style, and selection. The configuration surface for the line-stepped view.
IUIText / UITextComponentInterface + wrapperTMP-free read and update surface for a label: text, color, size, style, alignment, font, wrap, plus the zero-allocation SetCharArray. UITextComponent wraps a TextMeshProUGUI.
IUITextField / UITextFieldComponentInterface + wrapperTMP-free surface for an input field: text, focus, caret, interactable, colors, a per-character filter, and OnValueChanged / OnSubmit events. Implements IDisposable to unhook listeners.
UITextFactoryFactoryProduces IUIText instances (currently a performance-monitor-tuned label) and wraps an existing TextMeshProUGUI as IUIText without creating a GameObject.
ScrollTextArea / LineScrollTextAreaComponentThe two scrolling text widgets. MonoBehaviours, normally created through their builders rather than added by hand.
ScrollPhysicsHelperThe momentum and elastic-bounce simulation behind ScrollTextArea. Owns scroll position, velocity, deceleration rate, and edge resistance.
TMPSettingsValue bagA full TMP configuration with named presets and a Clone / Validate / ApplyTo workflow.
TMPUtil / UIUtil / DebugCanvasUtilStatic helpersTMP creation and measurement; DPI, layer, and coordinate helpers; debug-canvas and event-system bootstrapping.
UIAnchor / UIAnchors / UIAlignmentValue typesAnchor presets and the nine-point alignment enum, applied to a RectTransform via UIAnchor.ApplyTo.
UIMargin / UIPaddingValue typesPer-side external and internal spacing, with named presets and non-mutating With* builders.
UIFontReference / UIFontStyleValue typesA serializable, TMP-free font handle and a flags enum of typographic styles.
GradientBackgroundHelperA disposable two-color gradient panel backing built from a 2×2 texture.

Recipes

These recipes cover every public surface in the UI utilities. Each one is a self-contained answer to a single problem, so scan the recipe names, find the one that matches what you are building, and copy the solution. A few recipes assume a panel transform as the parent – that is whatever RectTransform the widget should live under, often a UI Layout stack.

Build a HUD label in code

Problem. You want a score readout or a timer in the corner of the HUD, built in code so it can be themed and positioned without an Inspector round-trip. You also want a wrapping caption label that shrinks to fit its box, without managing the TMP auto-size parameters by hand.

Solution. UITextBuilder is the fluent path: each call configures one aspect and returns the builder, and the terminal Build creates the GameObject, attaches a RectTransform and a TextMeshProUGUI, applies the anchor, assigns the object to the UI layer, and returns the live component. Only positive width and height values are applied to the rect, so a label can be left to size itself.

/* A score label pinned to the top-left of the HUD. Every call returns the builder,
 * so the whole widget is one readable chain. Build() hands back the live TMP component. */
var scoreLabel = new UITextBuilder()
    .WithName("ScoreLabel")
    .WithParent(panel)
    .WithText("SCORE 0")
    .WithFontSize(24f)                       /* clamped to [1, 500] points internally */
    .WithColor(Color.yellow)
    .WithFontStyle(FontStyles.Bold)
    .WithAlignment(UIAlignment.UpperLeft)    /* maps to TMP TopLeft */
    .WithAnchor(UIAlignment.UpperLeft)       /* anchor + pivot via UIAnchors.FromAlignment */
    .AtPosition(16f, -16f)                    /* anchored offset from the corner */
    .WithSize(220f, 36f)
    .Build();

/* A wrapping body label that shrinks to fit its box. Auto-size overrides the fixed
 * font size and scales the text between the min and max to fill the rect. */
var caption = new UITextBuilder()
    .WithParent(panel)
    .WithText("A long tutorial hint that should wrap and shrink to fit its panel.")
    .WordWrap()
    .AutoSize(true, min: 10f, max: 28f)
    .WithAlignment(UIAlignment.MiddleCenter)
    .Build();

/* TryBuild swallows construction exceptions and reports failure through the return
 * value instead, logging to the UI category. Handy in tooling that must not throw. */
if (new UITextBuilder().WithParent(panel).WithText("Ready").TryBuild(out var readyLabel))
{
    readyLabel.color = Color.green;
}

Define shared typography presets

Problem. Once your project has more than a handful of labels, their fonts and sizes want to live in one place so a design change is a single edit rather than a scavenger hunt through every widget.

Solution. TMPSettings is that place: a plain configuration object carrying every TMP property, with named presets for the common roles. The presets are shared instances, so the rule is to Clone before changing anything, then ApplyTo a component. Validate is there to catch out-of-range font sizes early when the numbers come from a config file or a user preference.

/* Start from the Header preset (24 pt, bold, centered) and tweak a copy. Cloning is
 * mandatory: mutating the shared preset would change every other consumer of it. */
var titleStyle = TMPSettings.Header.Clone();
titleStyle.Color = Color.cyan;
titleStyle.FontSize = 28f;

/* Validate throws ArgumentException on a font size outside [1, 500] or an inverted
 * auto-size range. Call it when the values came from outside the codebase. */
titleStyle.Validate();

/* ApplyTo writes the whole configuration to a TMP component in one pass (one mesh
 * rebuild). It throws if the component is null. */
titleStyle.ApplyTo(titleLabel);

/* The built-in presets cover the usual roles. Console is monospace-friendly and
 * near-white; Button turns the raycast target off so the label does not eat clicks
 * meant for the button behind it. */
TMPSettings.Body.ApplyTo(bodyLabel);
TMPSettings.Caption.ApplyTo(footnoteLabel);
TMPSettings.Console.ApplyTo(logLabel);

/* TMPUtil.Create adds a TMP component and applies a settings preset in one call,
 * the get-or-add shape for code that owns a bare GameObject. */
var hint = TMPUtil.Create(hintObject, TMPSettings.Caption.Clone());

Drive labels from gameplay code without importing TMP

Problem. You have a layer of gameplay or tooling code – a stats panel, an achievement toast, a dialogue box – that wants to push text into a label without caring that the label happens to be TextMeshPro underneath. You don’t want TMP types leaking into every assembly that touches UI text.

Solution. IUIText is that seam: it exposes reading and updating text, color, size, style, alignment, font, and wrap, all in TMP-free terms, and UITextComponent is the wrapper that delegates to a real TextMeshProUGUI. The standout member is SetCharArray, which updates the displayed text from a slice of a char buffer with no string allocation – the path a per-frame counter should take.

/* Wrap an existing TMP component from a prefab as IUIText, so the consuming code
 * never imports TextMeshPro. Returns null if the component is null. */
IUIText timer = UITextFactory.Wrap(prefabTimerLabel);

/* SetCharArray is the zero-allocation update. Format into a reusable char buffer and
 * push the slice every frame; no managed string is created, so the GC stays quiet
 * even for a readout that changes 60 times a second. */
int written = FormatSeconds(_timeBuffer, secondsRemaining);  /* writes into char[] */
timer.SetCharArray(_timeBuffer, 0, written);

/* The styling surface is TMP-free: a Scylla font-style flags value and a Scylla
 * alignment enum, converted internally so the caller never touches TMP types. */
timer.SetColor(Color.white);
timer.SetFontStyle(UIFontStyle.Bold | UIFontStyle.Italic);
timer.SetAlignment(UIAlignment.MiddleRight);

/* Always gate calls on IsValid when the underlying GameObject might have been
 * destroyed between frames (a label that belonged to a closed panel). */
if (timer.IsValid)
{
    timer.SetText("--:--");
}

/* The factory also builds a label tuned for performance-monitor overlays: stretched
 * to its parent, no wrap, raycast off, and a monospace SDF font fallback chain. */
IUIText fpsReadout = UITextFactory.CreateForPerformanceMonitor(panel, "FPS", fontSize: 14);

Wire up a text input field

Problem. Anyone who has wired a TMP_InputField by hand knows it is four nested objects before it accepts a keystroke. You want a name-entry box or a cheat-console command line, built in one chain rather than assembled piece by piece in the Inspector.

Solution. UITextFieldBuilder constructs the whole hierarchy (root with a background image, a clipped text area, an italic placeholder, and the input label), connects the edit callbacks, and layers the lot. The line mode picks the Enter behavior: SingleLine submits, MultiLine inserts a newline, MultiLineSubmit submits on Enter and inserts on Shift+Enter.

/* A single-line command field for an in-game console. SingleLine makes Enter submit;
 * OnEndEdit fires with the final string when the player presses it. */
var command = new UITextFieldBuilder()
    .WithName("CommandLine")
    .WithParent(consolePanel)
    .WithPlaceholder("Enter command...")
    .WithSize(640f, 32f)
    .WithFontSize(16f)
    .WithBackgroundColor(new Color(0f, 0f, 0f, 0.6f))
    .SingleLine()
    .OnValueChanged(text => UpdateAutocomplete(text))
    .OnEndEdit(RunCommand)
    .Build();

/* A seed-entry field that only accepts digits, using the Integer content type for
 * validation and the IUITextField abstraction so the consuming code stays TMP-free. */
IUITextField seedField = new UITextFieldBuilder()
    .WithParent(panel)
    .WithPlaceholder("Daily seed")
    .WithContentType(TMP_InputField.ContentType.IntegerNumber)
    .WithCharacterLimit(9)                    /* 0 means unlimited */
    .BuildAbstract();

/* The character filter is a per-character predicate evaluated before the value-changed
 * event; rejected characters are silently dropped. Here it hard-limits to digits even
 * if the content type were relaxed. */
seedField.SetCharacterFilter(char.IsDigit);
seedField.OnSubmit += value => StartRun(int.Parse(value));

/* The abstraction exposes focus, caret, and interactable without TMP. Dispose unhooks
 * the listeners; an input field that is torn down without Dispose leaks its callbacks. */
seedField.Focus();
seedField.CaretPosition = seedField.Text.Length;
/* ... later, when the panel closes ... */
seedField.Dispose();

Anchor a minimap, tooltip, or HUD panel to a corner

Problem. You want a floating damage number anchored to the top-center of an enemy’s health bar, a minimap pinned to a screen corner, or a tooltip that floats above its anchor point. You want to express that placement once and reuse it rather than recomputing raw RectTransform math at every call site.

Solution. UIAnchor captures an anchor min, anchor max, pivot, and offset as a single value, and UIAnchors ships the nine-point presets plus stretch variants. UIAlignment is the nine-point enum that bridges to them. UIMargin and UIPadding express external and internal spacing as per-side values with named presets.

/* Pull a preset and apply it to a RectTransform. UpperRight anchors and pivots the
 * element to the top-right corner so it grows inward from there. */
UIAnchors.UpperRight.ApplyTo(minimap);

/* Presets are immutable values; WithPosition and WithPivot return adjusted copies.
 * A tooltip anchored to the top-center of its target but pivoting from its own bottom
 * so it floats above the anchor point. */
var tooltipAnchor = UIAnchors.UpperCenter
    .WithPivot(0.5f, 0f)
    .WithPosition(0f, 8f);
tooltipAnchor.ApplyTo(tooltip);

/* Stretch presets resize with the parent. Stretch fills it entirely; StretchTop
 * spans the full width and pins to the top edge, the shape of a header bar. */
UIAnchors.StretchTop.ApplyTo(headerBar);

/* A custom anchor for a point the presets do not cover (one third up the left edge),
 * and a sub-region stretch whose pivot lands at the region center automatically. */
UIAnchors.Custom(0f, 0.33f).ApplyTo(sideMarker);
UIAnchors.CreateStretch(0.1f, 0.1f, 0.9f, 0.5f).ApplyTo(lowerPanel);

/* Padding is space inside an element (border to content); margin is space outside it
 * (element to its neighbours). Both ship presets (Zero, Small=4, Medium=8, Large=16,
 * ExtraLarge=24) and non-mutating With* builders. */
UIPadding cardPadding = UIPadding.Medium.WithTop(12f);
UIMargin rowGap = UIMargin.Small;

Bootstrap a debug canvas before any UI exists in the scene

Problem. There is a chicken-and-egg moment when a debug overlay needs to render before any UI exists in the scene: no canvas, no event system, nothing to parent to. You want an overlay that can spin itself up from scratch, even in a scene where no UI has been set up.

Solution. DebugCanvasUtil creates a screen-space overlay canvas with a very high sort order (so it draws over game UI) and guarantees an event system exists, resolving the Input System module at runtime and falling back to the legacy one. UIUtil covers the smaller plumbing: DPI scaling, UI-layer assignment, and screen-to-local point conversion.

/* Spin up a debug canvas from nothing. It is ScreenSpaceOverlay, constant-pixel-size,
 * has a GraphicRaycaster, sorts above normal UI, and survives scene loads by default. */
Canvas debugCanvas = DebugCanvasUtil.CreateDebugCanvas();

/* EnsureEventSystem is idempotent: it returns the scene's event system or creates one
 * (with the right input module) if none exists. Safe to call from any bootstrap path. */
DebugCanvasUtil.EnsureEventSystem();

/* Create a UI GameObject the framework's way: it gets a RectTransform and is placed on
 * the "UI" layer automatically. The overload sets position and size in one call. */
GameObject holder = UIUtil.CreateUIObject("LogHolder", debugCanvas.transform, 0f, 0f, 800f, 400f);

/* DPI scaling keeps a logical pixel size consistent across displays. On a 192 DPI panel
 * this returns 2.0; on a display that does not report DPI it falls back to 1.0. */
float pad = UIUtil.ScaleByDPI(12f);

/* Hit-test a pointer against a rect and convert a screen point into the rect's local
 * space, the two queries a custom drag or click handler always needs. Pass null for the
 * camera on an overlay canvas. */
if (UIUtil.IsPointInRect(pointerScreenPos, holder.GetComponent<RectTransform>(), null) &&
    UIUtil.ScreenPointToLocalPoint(pointerScreenPos, holder.GetComponent<RectTransform>(), null, out var local))
{
    HandleLocalClick(local);
}

Size a dialogue box or tooltip to fit its text

Problem. A speech bubble, a tooltip, or a dialogue box wants to size itself to the words it holds, which means knowing how tall the text will be before it is on screen. You want to measure a block of text accurately – accounting for wrap, font, and size – without disturbing anything visible.

Solution. TMPUtil measures against TMP’s own layout engine for accuracy, using a dedicated hidden component so nothing visible is disturbed. The key is that the measurement font and size must match the display component, or the numbers will not line up.

/* Create a hidden measurement component once and reuse it. It must use the same font
 * and size as the label being measured, or the results will not match what renders. */
TextMeshProUGUI measurer = TMPUtil.CreateMeasurementComponent(panel, dialogueFont, fontSize: 18f);

/* Ask how tall a block of wrapped text will be at a given width. This drives a dialogue
 * box that grows to fit the line, then shrinks back for the next short barked line. */
float height = TMPUtil.GetTextHeight(measurer, npcLine, constrainedWidth: 360f);
dialogueBox.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height + verticalPadding);

/* The line count and the per-line segments are available too, for a typewriter effect
 * that reveals one visual line at a time or a paged dialogue that breaks at the box edge. */
int lines = TMPUtil.GetWrappedLineCount(measurer, npcLine, 360f);
string[] segments = TMPUtil.GetWrappedLineSegments(measurer, npcLine, 360f);

/* Lighter font-metric estimates exist for when sub-pixel accuracy is not needed, such
 * as deciding how many lines fit in a fixed viewport. */
int fits = TMPUtil.GetVisibleLineCount(logLabel);
float lineHeight = TMPUtil.GetLineHeight(logLabel);

Build a momentum-scrolling combat log

Problem. You want a combat log or a chat panel that feels like a phone: drag it, flick it, watch it coast and settle, and feel a soft bounce when it hits the end. It should stream incoming lines automatically, let the player scroll back to read history, and stay memory-bounded so an hour-long session does not balloon the heap.

Solution. ScrollTextArea is that widget, and ScrollTextAreaBuilder configures it. Lines append into a bounded ring buffer (default capacity 1000), and the ScrollPhysics simulation handles flick velocity, deceleration, and elastic overshoot. Auto-scroll keeps the newest line in view until the player scrolls up to read history.

/* Build a momentum-scrolling combat log. The builder configures the buffer, the feel
 * of the physics, drag, the scrollbar look, and an optional background, then wires the
 * whole component together and returns it. */
ScrollTextArea log = new ScrollTextAreaBuilder()
    .WithParent(panel)
    .WithSize(420f, 260f)
    .UseDefaultMonoFont()
    .WithFontSize(15f)
    .WithBufferCapacity(500)                  /* oldest line is evicted past 500 */
    .WithAutoScroll()                         /* stick to the bottom as lines arrive */
    .WithPhysics(decelerationRate: 0.135f,    /* iOS-like coast; lower stops sooner */
                 elasticStrength: 0.3f,        /* how far it overshoots the edge */
                 elasticDuration: 0.5f)        /* how long the bounce-back takes */
    .WithDragScroll()
    .WithModernScrollbar()                     /* thin auto-hiding thumb, no track */
    .WithSelection()                           /* allow click-drag text selection */
    .Build();

/* Append lines as combat happens. With auto-scroll on, each new line keeps the view
 * pinned to the bottom unless the player has scrolled up to read back. */
log.AppendLine("<color=#ff6>Goblin</color> hits you for 4.");
log.AppendLines("You parry.", "You riposte for 7.", "<color=#6f6>Goblin falls.</color>");

/* Programmatic scroll control, for a "jump to latest" button or a cutscene that scrolls
 * the log on its own. animate uses an eased glide rather than a snap. */
log.ScrollToBottom(animate: true);
log.ScrollBy(-40f);                            /* nudge up by 40 px */

/* Pointer drag is fed in from the game's own input so the widget stays input-agnostic.
 * Begin, update, and end the drag with screen-space positions. */
log.HandleDragStart(pointerScreenPos);
log.HandleDrag(pointerScreenPos);
log.HandleDragEnd();                           /* releases into a momentum flick */

/* Status reads for HUD glue: a down-arrow indicator that shows only when scrolled up. */
bool showJumpButton = !log.IsAtBottom;

Build a line-stepped console or terminal output pane

Problem. A terminal does not coast. Press Page Up and it jumps a screenful; hold the up arrow and it steps line by line at a steady repeat rate. You want that discrete, keyboard-driven behavior for an in-game debug console or a retro text adventure’s output pane, not the smooth momentum physics of a chat panel.

Solution. LineScrollTextArea keeps a window of whole lines (default 20) over a ring buffer and moves the window in line and page steps. LineScrollTextAreaBuilder configures it with key-repeat timing rather than physics parameters.

/* Build a line-stepped console output view. Scroll timing controls key-repeat: the
 * delay before auto-repeat kicks in and the interval between repeats while held. */
LineScrollTextArea console = new LineScrollTextAreaBuilder()
    .WithParent(consolePanel)
    .WithSize(800f, 360f)
    .UseDefaultMonoFont()
    .WithFontSize(14f)
    .WithBufferCapacity(2000)
    .WithAutoScroll()
    .WithScrollTiming(initialDelay: 0.4f, repeatInterval: 0.05f)
    .WithEdgeBounce()                          /* small nudge when scrolling past an end */
    .WithClassicScrollbar(Color.gray, new Color(0.1f, 0.1f, 0.1f), width: 8f)
    .Build();

/* Append output as commands run. The window stays at the bottom while auto-scroll is on. */
console.AppendLine("> spawn boss");
console.AppendLine("Spawned 'AshWyrm' at (12, 0, 48).");

/* Feed held-key state every frame; the component handles the repeat timing internally,
 * stepping one line per repeat for the arrow keys and a full page for Page Up/Down. */
console.HandleScrollInput(scrollUpPressed: upHeld, scrollDownPressed: downHeld);
console.HandlePageScrollInput(pageUpPressed: pageUpHeld, pageDownPressed: pageDownHeld);

/* Discrete jumps for buttons or shortcuts. */
console.ScrollUpPage();
console.ScrollToTop();

/* Reset the repeat state when focus leaves the console, so a held key does not resume
 * scrolling the instant focus returns. */
console.ResetScrollInputState();

Add text selection and clipboard copy to a scroll log

Problem. The moment a console prints a stack trace, someone wants to copy it. You want click-drag selection and clipboard copy in a scroll view, driven from the game’s own pointer state rather than wired to a specific input backend.

Solution. Both scroll views expose HandleSelectionInput, SelectedText, and CopySelectionToClipboard through the same shape. Selection ranges are expressed with TextSelectionPosition (a logical line plus a character index) and TextSelectionRange, which are comparable value types so range math stays simple.

/* Drive selection from the game's pointer state. The method takes the mouse position,
 * the button-down and button-held flags, and reports whether the selection changed. */
bool changed = log.HandleSelectionInput(
    mousePosition: pointerScreenPos,
    leftButtonPressed: mouseDownThisFrame,
    leftButtonHeld: mouseHeld);

/* Read the current selection. SelectedText is the extracted string; Selection is the
 * range as anchor and cursor positions for custom highlight rendering or persistence. */
TextSelectionRange range = log.Selection;
string picked = log.SelectedText;

/* Select-all and copy, the two shortcuts every log should honor. CopySelectionToClipboard
 * writes the current selection to the system clipboard via GUIUtility. */
log.SelectAll();
log.CopySelectionToClipboard();
log.ClearSelection();

/* Map a screen point to the line under it, for a click-to-jump or context menu on a line. */
if (log.TryGetLineIndexFromScreenPoint(pointerScreenPos, out int lineIndex))
{
    InspectLogLine(lineIndex);
}

Light an unread badge when a chat log receives new lines off-screen

Problem. Your chat panel has a “jump to latest” button that should light up when the player has scrolled up and a new line arrives below the fold. You want a react-on-event approach rather than polling IsAtBottom every frame.

Solution. Both scroll views publish strongly-typed events through the Scylla event system (SEX) when publishing is enabled. Publishing is off by default to keep quiet widgets free of event traffic; you turn it on per widget.

/* Turn event publishing on for this log; off by default to avoid event spam from
 * widgets nobody is listening to. The builder's WithEventPublishing() does the same. */
log.SetEventPublishing(true);

/* Listen through the event facade. The first argument is the subscriber that owns the
 * subscription (the bus holds it weakly). A line-added event arriving while the player
 * is scrolled up is the cue to light an unread badge. */
ScyllaEvents.Listen<ScrollTextBufferLineAddedEvent>(this, evt =>
{
    if (!log.IsAtBottom)
    {
        ShowUnreadBadge();
    }
});

/* Scroll and drag events report position transitions, for a scroll-progress indicator
 * or analytics on how far players read back. */
ScyllaEvents.Listen<ScrollTextAreaScrolledEvent>(this, evt =>
    UpdateScrollIndicator(evt.NewPosition, evt.ScrollTextArea.MaxScrollPosition));

/* The line-stepped view publishes its own line-added and scrolled events, the latter
 * carrying a ScrollDirection so a listener can tell older-content from newer-content moves. */
ScyllaEvents.Listen<LineScrollTextBufferScrolledEvent>(this, evt =>
    Log.Debug($"Console scrolled {evt.Direction} to line offset {evt.NewOffset}", LogCategory.UI));

Add a gradient backing to a HUD panel

Problem. A flat panel reads as cheap. You want a subtle top-to-bottom gradient behind a HUD element to give it depth, without writing a custom shader or adding a heavier background component.

Solution. GradientBackground builds a two-color vertical gradient from a tiny 2×2 texture and stretches it to fill its parent, sitting behind the panel’s content and excluded from layout so it never disturbs a ContentSizeFitter or a layout stack. It is a plain disposable object, not a component, so it owns a texture that must be released when the panel closes.

/* Create a gradient backing under a panel. It parents itself, stretches to fill, and
 * drops to the back of the sibling order so content draws over it. */
var backing = new GradientBackground(
    parent: panelObject,
    topColor: new Color(0.12f, 0.14f, 0.20f),
    bottomColor: new Color(0.04f, 0.05f, 0.08f));

/* Recolor at runtime, for a panel that shifts hue with the player's health or alert
 * state. UpdateColors rewrites the 2x2 texture and re-uploads it to the GPU. */
backing.UpdateColors(Color.red * 0.3f, Color.black);

/* Hide and show without tearing it down. */
backing.SetVisible(false);

/* Dispose releases the owned texture and destroys the child GameObject. A gradient that
 * is dropped without Dispose leaks both the texture and the object. */
backing.Dispose();

API Sketch

The surface is the four builders, the two TMP-free interfaces and their wrappers, the two scrolling widget components plus the physics helper, the value types, and the static helper classes. Builders and components are constructed in code; the value types are plain structs and small classes.

/* ----- Builders (fluent; every setter returns the builder) ----- */
public sealed class UITextBuilder
{
    public UITextBuilder WithName(string name);
    public UITextBuilder WithParent(Transform parent);
    public UITextBuilder WithText(string text);
    public UITextBuilder WithFont(TMP_FontAsset font);
    public UITextBuilder WithFontSize(float size);
    public UITextBuilder WithFontStyle(FontStyles style);
    public UITextBuilder WithColor(Color color);
    public UITextBuilder WithAlignment(UIAlignment alignment);     /* also a TextAlignmentOptions overload */
    public UITextBuilder WithOverflow(TextOverflowModes overflow);
    public UITextBuilder WordWrap(bool enabled = true);
    public UITextBuilder RichText(bool enabled = true);
    public UITextBuilder RaycastTarget(bool enabled = true);
    public UITextBuilder WithAnchor(UIAnchor anchor);              /* also a UIAlignment overload */
    public UITextBuilder AtPosition(float x, float y);
    public UITextBuilder WithSize(float width, float height);
    public UITextBuilder AutoSize(bool enabled = true, float min = 10f, float max = 72f);
    public UITextBuilder WithLineSpacing(float spacing);
    public UITextBuilder WithCharacterSpacing(float spacing);
    public UITextBuilder WithSettings(TMPSettings settings);
    public TextMeshProUGUI Build();
    public bool            TryBuild(out TextMeshProUGUI text);
    public IUIText         BuildAbstract();
    public bool            TryBuildAbstract(out IUIText text);
}

public sealed class UITextFieldBuilder
{
    public UITextFieldBuilder WithParent(Transform parent);
    public UITextFieldBuilder WithText(string text);
    public UITextFieldBuilder WithPlaceholder(string placeholder);
    public UITextFieldBuilder WithFont(TMP_FontAsset font);
    public UITextFieldBuilder WithFontSize(float size);
    public UITextFieldBuilder WithTextColor(Color color);
    public UITextFieldBuilder WithPlaceholderColor(Color color);
    public UITextFieldBuilder WithBackgroundColor(Color color);
    public UITextFieldBuilder WithContentType(TMP_InputField.ContentType contentType);
    public UITextFieldBuilder SingleLine();
    public UITextFieldBuilder MultiLine();
    public UITextFieldBuilder MultiLineSubmit();
    public UITextFieldBuilder WithCharacterLimit(int limit);       /* 0 = unlimited */
    public UITextFieldBuilder WithAnchor(UIAnchor anchor);         /* also a UIAlignment overload */
    public UITextFieldBuilder AtPosition(float x, float y);
    public UITextFieldBuilder WithSize(float width, float height);
    public UITextFieldBuilder WithPadding(UIPadding padding);      /* also a float overload */
    public UITextFieldBuilder Interactable(bool enabled = true);
    public UITextFieldBuilder OnValueChanged(UnityAction<string> action);
    public UITextFieldBuilder OnEndEdit(UnityAction<string> action);
    public UITextFieldBuilder OnSelect(UnityAction<string> action);
    public UITextFieldBuilder OnDeselect(UnityAction<string> action);
    public TMP_InputField Build();
    public bool           TryBuild(out TMP_InputField inputField);
    public IUITextField   BuildAbstract();
    public bool           TryBuildAbstract(out IUITextField textField);
}

/* ScrollTextAreaBuilder and LineScrollTextAreaBuilder follow the same shape:
 * WithParent / WithSize / WithAnchor / WithFont / WithFontSize / WithTextColor /
 * WithPadding / WithBufferCapacity / WithAutoScroll / WithBackground / WithSelection /
 * WithScrollbar (Modern/Classic) / WithEventPublishing, then Build() / TryBuild(out ...).
 * ScrollTextAreaBuilder adds WithPhysics / WithDecelerationRate / WithElasticStrength /
 * WithElasticDuration / WithDragScroll / WithLineSnapping / WithViewportLineAlignment.
 * LineScrollTextAreaBuilder adds WithScrollTiming / WithScrollInitialDelay /
 * WithScrollRepeatInterval / WithEdgeBounce. */

/* ----- TMP-free interfaces ----- */
public interface IUIText
{
    GameObject     GameObject    { get; }
    RectTransform  RectTransform { get; }
    string         Text          { get; set; }
    float          FontSize      { get; }
    bool           IsValid       { get; }
    void SetText(string text);
    void SetCharArray(char[] chars, int start, int length);        /* zero-allocation */
    void SetColor(Color color);
    void SetFontSize(float size);
    void SetFontStyle(UIFontStyle style);
    void SetAlignment(UIAlignment alignment);
    void SetFont(UIFontReference fontReference);
    void SetRichText(bool enabled);
    void SetWordWrap(bool enabled);
}

public interface IUITextField : IDisposable
{
    string        Text          { get; set; }
    bool          IsFocused     { get; }
    bool          Interactable  { get; set; }
    int           CaretPosition { get; set; }
    GameObject    GameObject    { get; }
    RectTransform RectTransform { get; }
    void Focus();
    void Unfocus();
    void Clear();
    void SetTextColor(Color color);
    void SetFontSize(float size);
    void SetFont(UIFontReference fontReference);
    void SetPlaceholderColor(Color color);
    void SetCaretColor(Color color);
    void SetCharacterFilter(Func<char, bool> filter);
    event Action<string> OnValueChanged;
    event Action<string> OnSubmit;
}

public static class UITextFactory
{
    public static IUIText CreateForPerformanceMonitor(Transform parent, string name, int fontSize, UIFontReference font = null);
    public static IUIText Wrap(TextMeshProUGUI tmpText);
}

/* ----- Scrolling components ----- */
public sealed class ScrollTextArea : MonoBehaviour
{
    public ScrollTextBuffer Buffer        { get; }
    public ScrollPhysics    Physics       { get; }
    public int   LineCount        { get; }
    public float ScrollPosition   { get; }
    public bool  IsAtTop          { get; }
    public bool  IsAtBottom       { get; }
    public TextSelectionRange Selection    { get; }
    public string             SelectedText { get; }
    public void AppendLine(string text);
    public void AppendLines(params string[] lines);
    public void Clear();
    public void ScrollTo(float position, bool animate = false);
    public void ScrollToTop(bool animate = false);
    public void ScrollToBottom(bool animate = false);
    public void ScrollBy(float pixels);
    public void HandleDragStart(Vector2 screenPosition);
    public void HandleDrag(Vector2 screenPosition);
    public void HandleDragEnd(Vector2? screenPosition = null);
    public void ForceRefresh();
    public void SetEventPublishing(bool enabled);
    public bool HandleSelectionInput(Vector2 mousePosition, bool leftButtonPressed, bool leftButtonHeld);
    public void SelectAll();
    public void ClearSelection();
    public void CopySelectionToClipboard();
    public bool TryGetLineIndexFromScreenPoint(Vector2 screenPos, out int lineIndex);
}

public sealed class LineScrollTextArea : MonoBehaviour
{
    public LineScrollTextBuffer Buffer { get; }
    public int  LineCount   { get; }
    public int  WindowSize  { get; }
    public bool IsAtTop     { get; }
    public bool IsAtBottom  { get; }
    public void AppendLine(string text);
    public void AppendLines(params string[] lines);
    public void Clear();
    public void ScrollUpLine();
    public void ScrollDownLine();
    public void ScrollUpPage();
    public void ScrollDownPage();
    public void ScrollToTop();
    public void ScrollToBottom();
    public void HandleScrollInput(bool scrollUpPressed, bool scrollDownPressed);
    public void HandlePageScrollInput(bool pageUpPressed, bool pageDownPressed);
    public void ResetScrollInputState();
    /* selection and event members mirror ScrollTextArea */
}

/* ----- Static helpers ----- */
public static class UIUtil
{
    public const string UI_LAYER_NAME = "UI";
    public const float  STANDARD_DPI  = 96f;
    public static float      GetDPIScaleFactor(bool round = false);
    public static float      ScaleByDPI(float value);              /* also a Vector2 overload */
    public static int        GetUILayerID();
    public static bool       AddToUILayer(GameObject gameObject);
    public static bool       AddToUILayerRecursive(GameObject gameObject);
    public static GameObject CreateUIObject(string name, Transform parent = null);   /* + sized overload */
    public static void       ForceRebuildCanvas(Canvas canvas);
    public static Vector2    GetScreenPosition(RectTransform rt, Camera camera = null);
    public static bool       IsPointInRect(Vector2 screenPoint, RectTransform rt, Camera camera = null);
    public static bool       ScreenPointToLocalPoint(Vector2 screenPoint, RectTransform rt, Camera camera, out Vector2 localPoint);
}

public static class TMPUtil
{
    public static TextMeshProUGUI Create(GameObject go);                          /* + settings overload */
    public static TextMeshProUGUI Require(GameObject go);
    public static TextMeshProUGUI CreateMeasurementComponent(Transform parent, TMP_FontAsset font, float fontSize);
    public static int    GetWrappedLineCount(TMP_Text measure, string content, float width);
    public static float  GetTextHeight(TMP_Text measure, string content, float width);
    public static string[] GetWrappedLineSegments(TMP_Text measure, string content, float width);
    public static int    GetVisibleLineCount(TMP_Text text);
    public static float  GetLineHeight(TMP_Text text);
    /* plus Attach / Configure / SetAlignment / SetOverflow / SetWordWrapping / SetAutoSize /
     * GetPreferredWidth / GetPreferredHeight / GetPreferredSize / ForceUpdate / Clear */
}

public static class DebugCanvasUtil
{
    public const int    DEFAULT_SORTING_ORDER = short.MaxValue - 100;
    public const string DEFAULT_CANVAS_NAME   = "Scylla Debug Canvas";
    public static Canvas      CreateDebugCanvas(int sortingOrder = DEFAULT_SORTING_ORDER, string name = DEFAULT_CANVAS_NAME, bool persistAcrossScenes = true);
    public static EventSystem EnsureEventSystem();
}

/* ----- Value types ----- */
public struct UIAnchor   { public void ApplyTo(RectTransform rt); public UIAnchor WithPosition(Vector2 p); public UIAnchor WithPivot(Vector2 p); /* + Min/Max/Pivot/Position */ }
public static class UIAnchors { /* UpperLeft ... LowerRight, Stretch*, FromAlignment, Custom, CreateStretch */ }
public enum UIAlignment : byte { UpperLeft, UpperCenter, UpperRight, MiddleLeft, MiddleCenter, MiddleRight, LowerLeft, LowerCenter, LowerRight }
public readonly struct UIMargin  { /* Left/Right/Top/Bottom, Zero/Small/Medium/Large/ExtraLarge, With*, Scale */ }
public readonly struct UIPadding { /* Left/Right/Top/Bottom, Zero/Small/Medium/Large/ExtraLarge, With*, Scale */ }
public sealed class UIFontReference { public bool HasFont { get; } public TMP_FontAsset FontAsset { get; } public static UIFontReference FromFontAsset(TMP_FontAsset a); }
[Flags] public enum UIFontStyle { Normal, Bold, Italic, Underline, LowerCase, UpperCase, SmallCaps, Strikethrough, Superscript, Subscript, Highlight }

public sealed class GradientBackground
{
    public GradientBackground(GameObject parent, Color topColor, Color bottomColor);
    public void UpdateColors(Color topColor, Color bottomColor);
    public void SetVisible(bool visible);
    public bool IsVisible();
    public void Dispose();
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Settings reference

The tables below collect the defaults a freshly built widget reports and the named presets the value types ship.

TMPSettings presets, each a shared instance to Clone before mutating:

PresetSizeStyleNotable
Default16 ptNormalWhite, top-left, word wrap on, rich text on. The neutral starting point.
Header24 ptBoldCentered, word wrap off. Titles and section headings.
Body14 ptNormalTop-justified, word wrap on, 2 units of extra line spacing for readability.
Caption12 ptItalicLight gray, top-left. Footnotes and auxiliary text.
Button16 ptBoldCentered, ellipsis overflow, raycast target off so the label does not eat the button’s clicks.
Console14 ptNormalNear-white, top-left, truncate overflow, rich text on. Monospace-friendly log output.
InputField16 ptNormalBlack, left-aligned, overflow mode, raycast off so the TMP input field handles pointer events.

ScrollPhysics momentum defaults (exposed as constants for theming):

PropertyDefaultEffect
DecelerationRate0.135How quickly a flick coasts to rest. Lower stops sooner; the default mirrors the iOS feel. Clamped below an internal safe maximum.
ElasticStrength0.3How far the content overshoots when dragged past an edge. Higher feels rubberier.
ElasticDuration0.5Seconds the elastic bounce-back takes to settle.
VELOCITY_THRESHOLD1.0Speed below which momentum is considered stopped.

Ring buffer capacities:

BufferDefault capacityRangeNotes
ScrollTextBuffer1000 lines1 to 100000Oldest line is evicted once full. Set via WithBufferCapacity.
LineScrollTextBuffer1000 lines1 to 100000Same eviction, plus a default visible window of 20 lines.

UIMargin and UIPadding share the same named presets: Zero (0 px), Small (4 px), Medium (8 px), Large (16 px), ExtraLarge (24 px), each uniform on all four sides.

Best Practices

  • Reach for the builders, not hand-wiring. A TMP_InputField is four nested objects with specific anchoring; UITextFieldBuilder gets every one right and layers the result. Building widgets in code through the builders is less error-prone than authoring them as prefabs and keeps the configuration visible at the call site.
  • Define typography once with a shared preset. Put each text role in a TMPSettings preset (or clone a built-in one) and apply it everywhere that role appears. A later font or size change becomes a single edit rather than a hunt through every label.
  • Always clone a preset before mutating it. The static presets are shared instances. Changing a property on TMPSettings.Header directly changes it for every consumer; call Clone first, every time.
  • Use SetCharArray for anything that updates every frame. A timer, score, or live debug readout that assigns to Text allocates a string per frame and feeds the garbage collector. SetCharArray writes from a reusable buffer with no allocation, which is the difference between a smooth frame and a periodic GC hitch.
  • Pick the scroll view that matches the interaction. ScrollTextArea is for pointer-driven, momentum-feel content (chat, combat log). LineScrollTextArea is for keyboard-driven, line-discrete content (console, terminal). Using the momentum scroller where the player expects crisp Page Up steps feels wrong, and vice versa.
  • Size the ring buffer to the content, not to infinity. Both scroll buffers default to 1000 lines and evict the oldest past capacity, which bounds memory for an endless log. Raise it for a console that needs deep scrollback, lower it for a transient toast feed; don’t assume the default fits every case.
  • Match the measurement component’s font and size to the display. TMPUtil measurements are only accurate when the hidden measurement component uses the same font asset and point size as the label being sized. A mismatch produces tooltips and dialogue boxes that are subtly too tall or too short.
  • Dispose the things that own native resources. GradientBackground owns a texture and IUITextField owns event subscriptions; both expose Dispose. Tearing a panel down without disposing leaks the texture or leaves listeners hooked to a dead widget.
  • Define a UI layer in the project. The builders and UIUtil.CreateUIObject assign their output to a layer literally named UI. Without that layer the assignment fails (with a one-time warning) and the objects land on the default layer, which can break camera culling masks set up to isolate UI.
  • Keep event publishing off unless something listens. Scroll and line-added events are disabled by default. Turn them on per widget only where a listener exists (an unread badge, a jump-to-bottom button); leaving them off keeps quiet widgets out of the event bus entirely.
  • Express placement with the value types, not raw RectTransform math. A UIAnchors preset plus a UIPadding value reads as intent and survives reuse; recomputing anchor min, max, and pivot inline at each call site is where corner-case bugs hide.
  • Let the abstraction layer decouple gameplay from TMP. When gameplay or tooling code only needs to push text and react to edits, take an IUIText or IUITextField rather than a concrete TMP type. It keeps TextMeshPro out of those assemblies and makes the widgets straightforward to fake in a test.

Pitfalls