Dynamic Text

Forma exposes the same text contracts in its MonoGame and FNA variants. Runtime-loaded text uses UIFontFace, DynamicUIFont, TextLayoutEngine, and device-scoped glyph atlases. Existing SpriteFont applications remain supported through SpriteFontAdapter.

Installing the runtime-matched dynamic package gives each new UIContext a packaged Inter DynamicUIFont at 16 logical pixels. Set FormaDynamicTextDefaultEnabled=false in the application project to disable this initializer. Core-only and opted-out applications continue to resolve only explicitly assigned fonts. Applications can replace the default through UIContext.Theme.FontFamily or call DynamicTextDefaults.Install before constructing contexts.

Typed rich text and reveal

TextBlock.Inlines uses the existing typed Span, Run, LineBreak and InlineImage tree; it does not require BBCode parsing. Author that tree in Forma XAML. Spans inherit font, foreground, background, decoration, language, direction and optional Meta values into their children. A font override supplies a real font face/size, not a synthetic bold or italic approximation.

VisibleCharacters counts document graphemes, including combining sequences across inline boundaries. A nonnegative count takes precedence over VisibleRatio; the ratio is clamped to [0,1] and must be finite. CharactersBeforeShaping truncates at the reveal boundary and can change layout. The other existing reveal modes retain the complete shaped geometry and hide unrevealed glyphs, backgrounds and decorations together. Hidden text has no character bounds or active metadata region. Ellipsis is suppressed while reveal is incomplete. Inline images participate through AlternativeText; an empty alternative does not consume a character and remains visible under full reveal.

GetMetaUnderPosition takes a context-space point and queries the same visible shaped rectangles used for drawing. MetaClicked follows the existing pointer-press convention, with inherited opaque metadata as its argument. Hidden, disabled, clipped-row and synthetic-ellipsis regions do not activate links. Hosts decide what metadata means: Forma does not open URLs or dispatch external actions automatically.

This path retains the current per-inline/chunk shaping model. It does not yet provide paragraph-wide bidirectional reordering or joining across differently styled runs, or distinct visual-glyph-order semantics for each glyph reveal enum variant. Grapheme-atomic visibility across a style boundary is not a claim of cross-style glyph joining. Locale-aware word navigation remains separate. The runtime path uses no new reflection or dynamic compilation.

Native IME input

On native macOS, editable controls use Option+Left/Right for word movement and Command+Left/Right for line boundaries. In TextEdit/CodeEdit, Command+Up/Down moves to document boundaries, while Option+Up/Down uses hard paragraph boundaries; Command+Left/Right follows displayed rows when text wraps. Shift extends the selection without moving its anchor. Word/line deletion uses the same modifier policy. Windows/Linux retain Control-based word navigation. Plain character movement respects grapheme boundaries even without an assigned font.

UIComponent connects native text input automatically; hosts should not forward a second GameWindow.TextInput handler into its context. SupportsTextComposition reports whether the runtime exposes real preedit, complete-string commits and native candidate-area placement. SupportsFocusedTextComposition additionally checks the focused editor. The supported control is editable LineEdit; TextEdit and other character consumers retain their ordinary text route, but do not claim native composition or candidate-placement parity.

The source-built MonoGame SDL3 native backend carries SDL_EVENT_TEXT_EDITING separately from SDL_EVENT_TEXT_INPUT. Preedit does not change Text, raise TextChanged or enter undo history. A complete UTF-8 commit becomes one managed string and one LineEdit edit, including selected-text replacement, surrogate pairs and combining sequences. SDL scalar selection offsets are converted to UTF-16 at the MonoGame boundary. Existing per-character MonoGame subscribers still receive UTF-16 characters after the complete-string event; Forma consumes only the latter on this route. Native candidate navigation and Enter are not synthesized into LineEdit commits.

Candidate rectangles follow the shaped/scrolled caret, control transforms and DisplayScale. They are sent in drawable pixels; MonoGame converts using the live drawable-to-window ratio before calling SDL's point-space text-input-area API. UIComponent divides the drawable viewport by DisplayScale when assigning the context's logical ViewportSize, so root layout and popup limits remain in the same coordinates as the controls. Focus transfer and local cancellation clear the native session rather than committing marked text. Window deactivation also resets retained keyboard modifiers; reactivation restarts input for the still-focused editor. Modifiers pressed during preedit remain tracked across commit, while candidate-navigation keys remain owned by the IME. Deactivation cancels pointer selection, captured presses and active drag/drop without synthesizing a release, click or drop; logical focus and the existing text selection remain available when the window becomes active again.

Platform cancellation visits the complete retained visual tree, not only the focused/captured control: scroll observers, nested split handles and hosted SubViewportContainer contexts also own gestures. Cancellation-only cleanup covers Slider, ScrollBar (including drag-node inertia and pending smooth motion), SpinBox drag/repeat, ScrollContainer touch inertia, SplitContainer and its helper draggers, ColorPicker (without flushing a deferred change or adding a recent preset), tab reordering, Tree column/range/edit-button gestures, rich-text selection/autoscroll, CodeEdit minimap, graph element/group/resize/connection/box/pan/minimap gestures, and list/dialog multi-click tracking. LineEdit/TextEdit and BaseButton retain their existing cancellation path. Controls with only immediate press actions or stateless release actions need no gesture flag cleanup; cancellation never dispatches their release handler. Committed values, layout offsets, selection and focus are retained. VirtualJoystick clears IsPressed but retains Value, without synthesizing Released or ValueChanged. A later real gesture starts normally.

The APIs are discovered using typed reflection delegates with trimming annotations, not dynamic code generation. Existing MonoGame 3.8.5 packages and FNA continue to compile and use their character-only adapters. Browser/SDL2 and old native binaries report unsupported, not simulated IME coverage. A new managed assembly alone does not upgrade an old native runtime. Event enum values are appended and the existing native event union is unchanged; text payloads are copied from platform-owned storage before the next poll. Rich events are opt-in, preserving legacy native callers.

Focused regression commands, from the enclosing Textus checkout:

dotnet test Forma/tests/Forma.Tests/Forma.Tests.csproj \
  --filter 'FullyQualifiedName~NativeTextCompositionTest|FullyQualifiedName~PlatformInputCancellationTest|FullyQualifiedName~ModalInputBoundaryTest|FullyQualifiedName~UITest.LineEdit'
dotnet test Forma/tests/Forma.Tests/Forma.Tests.csproj -p:FormaRuntime=FNA \
  --filter 'FullyQualifiedName~NativeTextCompositionTest|FullyQualifiedName~PlatformInputCancellationTest|FullyQualifiedName~ModalInputBoundaryTest|FullyQualifiedName~UITest.LineEdit'
dotnet test MonoGame/Tests/MonoGame.Tests.DesktopGL.csproj \
  '-p:DefaultItemExcludesInProjectFolder=Assets/Projects/obj/**' \
  --filter 'FullyQualifiedName~TextCompositionIndexTest'

The MonoGame exclusion prevents earlier generated project-fixture assembly metadata from entering the framework test compilation; it does not exclude any test source.

These are synthetic routing/Unicode/lifecycle regressions, not macOS IME acceptance. Release acceptance still requires a newly source-bound SDL3 native build and the authorized real-window session: select a real OS IME, compose without committed changes, navigate and commit a candidate, cancel, switch between two LineEdits, move/resize the window and caret on Retina, and leave/re-enter focus with modifiers held. That user-controlled run must verify the visible candidate popup and retained text/undo behavior. Do not reuse earlier immutable graphics build evidence for changed native source, or change system input settings without authorization.

Packages and Deployment

Use the package matching the application's framework:

Runtime Native-free compatibility Dynamic text
MonoGame Forma.MonoGame Forma.DynamicText.MonoGame
FNA Forma.FNA Forma.DynamicText.FNA

The core package is the native-free compatibility profile for restricted platforms and authorized console ports. It does not include FreeType, HarfBuzz, or their native libraries. Trim-only and NativeAOT package consumers are validated for both peers on macOS arm64, including native-free SpriteFont and optional dynamic-text graphical profiles. Run bash scripts/test-nativeaot-package-consumer.sh to reproduce the packed-artifact gate. Other RIDs remain unsupported until equivalent executable gates pass. Actual console support remains conditional on the selected MonoGame/FNA platform port, platform-holder approval, and validation on authorized hardware.

The optional dynamic package resolves FreeType and HarfBuzz native assets for its declared runtime identifiers. Publish and test every target RID in a clean environment; do not rely on system-installed libraries. Ship font licenses and the repository third-party notices with redistributed fonts. Forma selects the permissive FreeType License and HarfBuzz's MIT license. Binary redistribution requires retaining their acknowledgments and notices, which make compliance enforces; neither selected license requires a source offer. Modified or source redistribution must be reviewed against the corresponding upstream terms rather than inferred from this binary-package conclusion.

Internal Backend Boundary

UIFontFace, DynamicUIFont, and TextLayoutEngine do not expose FreeTypeSharp, HarfBuzzSharp, native handles, or platform font types. UIFontFace delegates face metadata, character/glyph lookup, metrics, variations, shaping, rasterization, diagnostics, and disposal to an internal backend contract. The normal Forma.DynamicText.<Runtime> build selects FreeTypeHarfBuzz and preserves the existing desktop package behavior.

Authorized source builds may set FormaDynamicTextBackend=External and provide FormaDynamicTextBackendSource pointing to a source file compiled into Forma.DynamicText. That file defines the internal ExternalDynamicTextBackend implementation. Selection is compile-time: there is no assembly scanning, reflection activation, runtime generic construction, or public backend API. The source remains in authorized infrastructure when it contains platform SDK details.

Run make static-font-backend to publish and execute the NDA-neutral platform-adapter spike for both runtime peers. The gate verifies the unchanged public face API and rejects FreeType/HarfBuzz managed dependencies and sidecar libraries from the spike output. This proves the replacement boundary and packaging shape; target-specific font quality, lifecycle, and policy remain platform validation work.

DynamicTextNativeDiagnostics.Current reports the target RID, logical native library names, and NuGet packaging sources without scanning loaded modules or exposing handles. Forma owns one direct entry point, FT_Set_Var_Design_Coordinates, against FreeTypeSharp's freetype library name. All other FreeType calls use FreeTypeSharp; shaping uses HarfBuzzSharp and its libHarfBuzzSharp native assets. Forma owns pinned-memory, FreeType-library, and FreeType-face safe handles. HarfBuzzSharp owns its blob, face, font, and buffer handles. This path registers no unmanaged callbacks and uses no runtime-generated marshalling.

Missing, incompatible, or rejected native font libraries fail face creation with FontLoadException and FontLoadErrorCode.NativeFailure. The public message is bounded and stable; loader details remain available through InnerException for host diagnostics. Run make native-font-failures to exercise missing files, invalid binaries, and valid libraries with missing FreeType exports in fresh processes for both peers. Packed dynamic consumers also require exactly one loaded FreeType and HarfBuzz module from their publish directory.

MGCB/XNB is not required for dynamic text. MonoGame MGCB SpriteFonts and FNA-compatible XNB SpriteFonts are optional offline compatibility routes.

Release Budgets

The dual-runtime render smoke enforces these deliberately conservative ceilings on supported graphical CI hosts. The August 2026 Apple M4 Max baseline measured MonoGame/FNA respectively at 1.9/9.7 ms cold face load, 0.19/0.24 ms first shape, 0.24/0.27 ms first raster plus upload, 1.0/1.1 ms per 1,000 warm layout lookups, 6.4/2.5 ms per 100 warm draws, 4.6/5.5 ms fallback-heavy layout, and 2.5/3.3 ms atlas churn.

Operation Release ceiling
Cold face load 1,000 ms
First shape 500 ms
First glyph raster and upload 500 ms
1,000 warm layout lookups 500 ms
Warm layout cache hit rate at least 99%
100 unchanged warm draws 1,000 ms and zero managed allocation
Fallback-heavy layout 500 ms
One-page atlas churn 2,000 ms
Core managed assembly 2 MiB
Dynamic-text managed assembly 256 KiB

The retained layout cache is bounded at 512 entries. Device-scoped Alpha8 atlases are bounded at eight 2048x2048 pages and 32 MiB. Budget failures block release; measurements are performance gates, not cross-machine throughput promises.

Loading and Ownership

using var latinFace = UIFontFace.FromProjectFile(projectDirectory, "Fonts/Inter-Regular.ttf");
using var arabicFace = UIFontFace.FromStream(File.OpenRead("Fonts/NotoSansArabic.ttf"));
var font = new DynamicUIFont(latinFace, 18, UIFontHinting.Default, arabicFace);

var label = new Label
{
    Text = "Forma مرحبا",
    UIFont = font,
    Language = "ar",
};

Faces can also be loaded from ReadOnlyMemory<byte>. Forma copies and pins bounded source bytes for the native face lifetime. The application owns faces and must keep them alive while fonts or layouts can use them, then dispose them idempotently. Controls, DynamicUIFont, and immutable TextLayout instances do not own faces.

Fallback order is deterministic and resolved per grapheme cluster. Put the normal UI face first, then script and emoji faces. Unsupported input reaches glyph 0 (.notdef) after the chain is exhausted; it is not replaced with ? and does not throw.

Use UIFontHinting.Light for small grayscale UI text that needs vertical pixel alignment while preserving fractional horizontal advances and inter-glyph spacing. Dynamic glyphs rasterize at a minimum 2x density and downsample linearly on 1x displays, avoiding low-resolution spacing artifacts without changing HarfBuzz layout metrics.

Layout and Display Density

Font sizes and all TextLayout geometry use logical UI units. UIContext.DisplayScale controls the physical glyph raster size. Moving from 1x to 2x rerasterizes or reuses density-specific cache entries without changing line breaks, caret positions, or logical bounds.

var options = new TextLayoutOptions(
    maxWidth: 320,
    wrapping: TextWrapping.Word,
    direction: TextDirection.Auto,
    locale: "he");
var layout = ui.TextLayoutEngine.Layout(font, text, options);
var caret = layout.GetCaretPosition(utf16Index);
var hit = layout.HitTest(pointerInLayout);
var selection = layout.GetSelectionRectangles(startUtf16, lengthUtf16);

UTF-16 offsets, grapheme clusters, visual clusters, and glyph IDs are distinct. Use layout movement, hit-testing, word-boundary, and selection APIs instead of incrementing code units or measuring substrings.

OpenType and Variable Fonts

TextLayoutOptions.OpenTypeFeatures accepts immutable four-character OpenType tags. Label forwards features with SetOpenTypeFeatures. Variable coordinates belong to DynamicUIFont identity:

var variable = new DynamicUIFont(
    face,
    24,
    UIFontHinting.Default,
    new[] { new UIFontVariationCoordinate("wght", 650) });
label.UIFont = variable;
label.SetOpenTypeFeatures(new[]
{
    new UIFontOpenTypeFeature("liga", 1),
    new UIFontOpenTypeFeature("kern", 1),
});

Cache Budget and Recovery

Each UIContext owns a glyph cache per GraphicsDevice. The default hard limits are eight 2048x2048 Alpha8 pages and 32 MB. UIContext.DynamicGlyphDiagnostics reports pages, glyphs, occupancy, hits, misses, uploads, evictions, failures, and bytes. Immutable page snapshots are available through GetDynamicGlyphAtlasPages; ClearDynamicGlyphCache clears pages between draws. Device reset recreates textures from retained grayscale pages. Active-frame budget exhaustion skips the unavailable glyph, records a diagnostic, and retries normally on later frames instead of allocating beyond the budget or terminating the process.

SpriteFont Compatibility

Keep SpriteFont when the application needs a fixed glyph set, pixel-art sampling, a deterministic offline atlas, minimal native dependencies, or legacy XNA-compatible deployment. Assigning a control's existing Font property installs a cached SpriteFontAdapter; no source rewrite is required.

Migrate without changing layout intent:

// Before: offline SpriteFont.
var column = new VBoxContainer { Separation = 8 };
column.AddChild(new Label { Font = content.Load<SpriteFont>("UI"), Text = "Settings" });
column.AddChild(new Button { Font = content.Load<SpriteFont>("UI"), Text = "Apply" });

// After: retain the same controls and layout properties; change only font selection.
using var face = UIFontFace.FromProjectFile(projectDirectory, "Fonts/Inter-Regular.ttf");
var uiFont = new DynamicUIFont(face, 16);
column.Children.OfType<Label>().Single().UIFont = uiFont;
column.Children.OfType<Button>().Single().UIFont = uiFont;

During rollout, keep the original SpriteFont loaded and expose an application switch:

void SelectCompatibility(bool compatibility)
{
    label.UIFont = compatibility ? new SpriteFontAdapter(spriteFont, 16) : dynamicFont;
}

The catalog header demonstrates this rollback path in both runtime hosts.

Compatibility Policy

Forma 0.x preserves the parallel Font and UIFont properties during migration. Additive text contracts are minor-version changes. Existing Font members will not be silently reinterpreted or removed; any future obsoletion forwards through SpriteFontAdapter for at least one minor release, and removal requires a documented major-version decision. Assigning both properties remains last-assignment-wins.

Catalog Stories

The exact typography story names are Dynamic Sizes, Letter Spacing, Display Density, Fallback Chain, Shaping and Features, Bidirectional Text, Wrapping and Selection, SpriteFont Compatibility, Atlas Inspector, and Failure States. Their stable identifiers are the kebab-case names prefixed with catalog-, such as catalog-fallback-chain.