Native Text Input

Status: implemented, not yet verified on a device. Every file described here exists and the C# compiles against the engine, but none of it has run on hardware or in a browser — nothing in this system is unit-testable, so the checklist at the bottom of this page is the actual acceptance gate. Delete this admonition once it has been walked on iOS, Android and Web.

BetterTextField is the app’s only text input. It looks and lays out like a UI Toolkit TextField, but on every shipping platform the text itself is drawn and edited by an OS text view floating over the Unity surface — a UITextView on iOS, an EditText on Android, an <input>/<textarea> on Web. UI Toolkit keeps the box: layout, padding, background, border, focus styling, and the value/ChangeEvent contract that the rest of the app talks to.

Why

Unity’s own text editing loses on all three platforms, each for a different reason:

  • Web — a software keyboard only opens for a focused DOM input, and it only opens if focus() happens inside the browser’s own gesture handler. Unity’s WebGL keystroke path also drops the first character, mishandles dead keys and IME, and fights for keyboard capture.

  • iOS / Android — Unity’s TouchScreenKeyboard gives no return-key control (no Send), no submit callback, no caret placement from a tap, no selection handles, no magnifier, no system copy/paste menu, no dictation, and no password-manager AutoFill.

  • Everywhere — selection, spell-check, autocorrect and the clipboard are deeply platform-specific. Reimplementing them inside UI Toolkit is a permanent tax; borrowing the OS implementation is free and always current.

The things users notice most (place the caret by tapping once, drag to select, long-press for Paste, autofill a password) are only possible if a real OS text view receives the touch. That single requirement drives most of the design decisions below.

Division of responsibility

Concern Owner Notes

Layout, size, position

UI Toolkit

The native view’s frame is derived from the element every frame. Layout is never driven the other way, with one exception: content height (see Multiline growth).

Background, border, corner radius, glass

UI Toolkit

The native view is fully transparent and draws nothing but glyphs, caret and selection. This is the biggest change from today’s Web overlay, which hardcodes its own pill in .jslib.

Padding

UI Toolkit, pushed

Transferred, because the native view’s hit box has to be the element’s. The frame covers the whole field and the padding rides along as a native inset, so a tap anywhere the user would call "the input" lands on the native view. See Coordinates.

Glyphs, caret, selection, magnifier, autocorrect, clipboard, dictation, AutoFill

Native

The entire reason the system exists.

Text state of record (value)

UI Toolkit

Native pushes every edit into value, which fires ChangeEvent<string> exactly as a plain TextField does. Programmatic writes to value push back into the native view.

Focus

Both, mirrored

Whoever gains focus first tells the other. UI Toolkit focus is kept in sync so :focus USS, focus rings and Focus() keep working.

Font, colour, size, alignment, caret/selection colour

UI Toolkit, pushed

Read from resolvedStyle and pushed on change, so USS stays the single place styling is authored.

The invariant that ties it together: exactly one renderer draws the text at any moment. The inner UI Toolkit text element is blanked (opacity: 0, never visibility, see Visibility and suppression) whenever the native view is showing. Because value is always mirrored, either renderer can take over on any frame — which is what makes the editor path, the suppressed path (see Visibility and suppression) and a disabled field all work with no special cases.

That invariant is why Fonts matters: every suppression swaps renderers, and a swap between Figtree-in-UITK and whatever the OS would otherwise pick is a visible pop. Both renderers now draw the same face, so the swap is invisible — which is the only reason it can be used as freely as it is here.

[[Authoring the box]] === Authoring the box

It also decides where a field’s USS goes, and getting it wrong fails silently and only on device:

The box goes on the field; only text properties go on the inner input. Size, padding, background, border and corner radius belong on the element carrying your own class. The inner .unity-base-text-field__input is the element that gets blanked, so a background or radius painted there vanishes for exactly as long as the field is in use and comes back the moment anything suppresses the native view — which reads as "the field loses its background while I type". Colour, font size, weight and alignment do belong on the inner element, because that is where UI Toolkit resolves them for the text and where Style transfer reads them back.

Padding on the field rather than the input is not just tidier, it is what the frame maths expects: the native inset is derived as the difference between the two boxes (see Coordinates), so padding authored on the field is what puts the native glyphs where UI Toolkit would have drawn them.

Author the margin. TextField carries a horizontal margin from the theme, so a field left to inherit it stands narrower than everything around it.

.input-modalfield (MultimodalStyles.uss), .chatfield / .chattext (ConversationStyles.uss) and .debug-infosearch (DebugInfoStyles.uss) are all the same recipe.

Anatomy

File Role

Assets/Scripts/UI/Elements/BetterTextField.cs

The TextField subclass, and the whole UI half of the system. Per instance: the style/frame diff, the text mirror, growth, and the focus handshake. Statically: the registry of live fields, the LateUpdate driver that ticks them, and the state that is nobody’s field in particular — which one holds the keyboard, and Web keyboard capture. No platform #if beyond "is there a backend".

Assets/Scripts/SystemNative/NativeTextInput.cs

One C# façade over the three backends — the only file with per-platform #if, written in the …Impl shape every other SystemNative class uses. It sits with its own native halves below rather than next to the element; nothing in it knows what a VisualElement is.

Assets/Plugins/iOS/NativeTextInput.mm

UITextView registry as subviews of GetAppController().rootView.

Assets/Plugins/Android/systemnative.androidlib/src/main/java/ai/promethist/systemnative/NativeTextInput.kt

EditText registry added to the activity’s content view.

Assets/Plugins/WebGL/NativeTextInput.jslib

DOM <input>/<textarea> registry (today’s WebOverlay.jslib, renamed), plus the keyboard focus guard and the iOS focus-scroll animation warm-up, which both stay exactly as documented in Web Workarounds.

WebKeyboard.jslib is unrelated and stays: it backs plain TextField`s (debug UIs) through Unity’s `JS_MobileKeyboard_* hooks.

The protocol

One id-keyed registry per platform, one shape for all three. Every call is C# → native; every event is native → C#, pushed, never polled.

Table 1. Commands
Call Notes

Create(options, listener) → id

options is a NativeTextInputOptions carrying what cannot change cheaply later: multiline, keyboard type, return-key behaviour, secure entry, read-only, autocorrect, max length. The listener is what the four events below are delivered to, keyed by the returned id. First call also wires the platform’s callbacks, which is why it must happen on the Unity thread.

Destroy(id)

Also drops the listener, so the caller never unregisters anything.

SetFrame(id, x, y, w, h)

Unity screen pixels, top-left origin. The field’s whole box, padding included. See Coordinates.

SetPadding(id, padding)

Where the text sits inside that frame — textContainerInset / setPadding / CSS padding. Diffed separately from the frame, since it changes when the USS does and never while a field merely moves.

SetStyle(id, style)

A NativeTextStyle: font size, weight and slant, colour, placeholder colour, caret colour, selection colour, horizontal + vertical alignment. Pushed only when the resolved values change — the struct is also what the caller diffs against, through its own Matches. Colours are packed RGBA32 and everything else is a primitive, so the same ten values cross a DllImport, a .jslib and a JNI call unchanged. Letter spacing is not pushed — nothing in the project authors it, and it was not worth a fourth place to get wrong. Neither is the font family: the app has one face and each backend loads it itself (see Fonts), which avoids marshalling a string every time a colour changes.

SetVisible(id, visible)

Show/hide and interactivity. A hidden view never takes touches.

SetText(id, text), SetPlaceholder(id, text)

SetSelection(id, start, end)

Driven by BetterTextField.SelectAllText/SelectTextRange and by selectAllOnFocus.

SetFocused(id, focused)

Programmatic focus, for field.Focus() and for blurring on suppression.

GetText(id, buffer, size) → byteLength

Pull path used only after a TextChanged event, so never per frame. Returns the required length so the shared buffer can grow instead of truncating at 4 KB as today.

Events arrive on the INativeTextInputListener handed to Create, so the platform layer resolves the id and the UI layer never keeps a table of its own. BetterTextField implements it explicitly: the four methods are the native side’s business, not the element’s API.

Table 2. Events
Event Notes

TextChanged(id)

Flag only; C# pulls the text. Keeps the callback signature allocation-free on every platform.

ContentHeightChanged(id, heightPx)

Height the text needs at the current frame width. Drives Multiline growth.

FocusChanged(id, focused)

Covers every blur source: tapping elsewhere, the Android IME back key, iOS resigning first responder, a DOM blur.

Submitted(id)

Return/Send pressed on a single-line field. The native side keeps the keyboard up.

Transport follows the conventions of its neighbours in SystemNative (WebView.cs is the reference): [DllImport("__Internal")] plus MonoPInvokeCallback statics on iOS, makeDynCall in .jslib on Web, AndroidJavaProxy marshalled through SynchronizationContext.Post on Android. Callbacks are static and id-keyed, not per-instance delegates, to keep IL2CPP happy and avoid one GC handle per field.

This is a deliberate departure from today’s GetAndClear* polling. Polling costs a per-field UTF-8 marshal of the whole string every 16 ms even when nothing was typed; it also cannot report a height change without a second poll. With events, an idle field costs zero interop.

Coordinates

SetFrame takes the field’s whole box (worldBound) and SetPadding the gap between it and where the text is drawn, both in Unity screen pixels, origin top-left:

var outer = worldBound;                                     // panel points, top-left origin
var text = _input.LocalToWorld(_input.contentRect);         // the inner element's content box
var padding = (text.x - outer.x, text.y - outer.y, outer.xMax - text.xMax, outer.yMax - text.yMax);
var scale = new Vector2(Screen.width / panel.visualTree.layout.width,
                        Screen.height / panel.visualTree.layout.height);

Four notes on why it is this and not something else:

  • Top-left, not Unity’s bottom-left. UI Toolkit panels, UIKit, Android views and CSS are all top-left. Today’s Web path flips to bottom-left in C# and flips back in JS; that goes away.

  • The whole element, not just the text. The native view is what receives the tap, so its box has to be the box the user aims at — which is worldBound, the same one UI Toolkit’s hit test uses. Framing it to the text alone left every tap in the padding with UI Toolkit: the 20 px strip left of the chat composer’s text, and the 16 px either side of the multimodal input. UI Toolkit answers such a tap by focusing the field and asking native to follow, which works on iOS and Android and cannot raise the keyboard on iOS Safari — so on Web the first tap there appeared to do nothing.

  • Padding as a difference, not as resolvedStyle.padding.* Subtracting the two rects also covers the inner element’s own padding and border and wherever UI Toolkit chose to place it, with no assumption about the field’s internal structure. The glyphs land exactly where UI Toolkit would have drawn them, so this is a hit-box change and nothing visible moves.

  • Pixels, converted natively. Each backend divides by its own view size (rootView.bounds.width / screenWidthPx, unityView.width / screenWidthPx, canvasRect.width / canvas.width) rather than trusting UIScreen.scale or devicePixelRatio. Same arithmetic, but immune to any future render-scale or DPI drift — and the Web backend already has to do exactly this because the canvas CSS box and its backing store differ.

There is no RuntimePanelUtils.PanelToScreen (only ScreenToPanel), hence the explicit ratio against panel.visualTree.layout — valid for the app’s screen-space panels.

Frame flow

BetterTextField keeps a static registry of every attached field and ticks it from a LateUpdate, the same hook LiquidGlassManager uses to keep its scopes on top of moving chrome, and for the same reason: the things that move UI are transforms and ancestor layout, and neither fires a GeometryChangedEvent on the field itself. A field inside a SafeAreaContainer whose content margins ease as the keyboard opens never sees a local layout change, so an event-driven sync would latch at the wrong position for the whole animation.

The LateUpdate comes from a hidden GameObject created on demand by the first field to attach, so a field works inside a popup instantiated at runtime with no owning MonoBehaviour — the same self-sufficiency the Web overlay had. It is the only reason a GameObject is involved at all.

Per registered field, per frame:

  1. Evaluate the visibility predicate; on a change, SetVisible and flip the inner text element’s visibility.

  2. If shown: recompute the frame, hold it for as many ticks as Unity’s own rendering is queued (see and which way it points), and push it only if it moved by more than half a pixel.

  3. If the resolved style hash changed, push the style.

  4. Apply anything native pushed since the last frame (text, height, focus, submit), on the Unity thread.

Everything is diffed, so a static screen full of fields costs a handful of float comparisons. The per-element schedule.Execute(…​).Every(16) ticks in today’s implementation go away.

A freshly attached field re-pushes everything unconditionally for its first WarmupTicks ticks instead of relying on the diff. On Android the native view is created a post behind the call that returned its id, so the first diffed pushes can land before anything is listening for them; a field never repeats a push on a static screen otherwise, so nothing is lost by forcing it for a handful of frames after attach.

Presentation lag, and which way it points

The intuition that a native view can only ever be "one frame behind Unity" — Unity computes the frame, the OS merely composites the view — is wrong, and backwards. The two reach the glass by different routes with different latencies:

  • The native view’s new frame is committed at the end of the main thread’s turn and composited at the next display refresh. Nothing is queued.

  • Unity’s pixels for that same frame go through the render thread and the GPU with up to QualitySettings.maxQueuedFrames (2 by default, and nothing here changes it) frames in flight.

So an unaided native view leads Unity’s own image by one to two frames, and on a moving field the text arrives before the box it belongs to. Two multipliers made that unmissable rather than academic: QualityManager caps the app at 30 fps during a conversation, so a frame is 33 ms; and `SafeAreaContainer’s ease closes 45 % of the remaining distance per frame at that rate. Two frames of queue therefore put the native text 70 % of the way up while the pill was still drawn at the bottom — most of the travel, not "a few pixels".

BetterTextField.Delayed compensates directly: it keeps the last few computed frames and pushes the one from NativeTextInput.PresentationDelayFrames ticks ago, holding the native view exactly as stale as the image it has to sit inside. The delay is the platform layer’s to answer — the queue depth on iOS and Android, and zero on Web, where the browser paints the DOM element and the canvas in the same compositor frame and there is nothing to compensate for.

That depth is a proxy for a latency rather than a measurement of one, and on device it proves to be one frame too generous: compensating all of it overshoots and the text trails its box instead of leading it, because the oldest queued frame is usually presented before the next tick reads it. So PresentationDelayTrim takes a frame back off, which is what makes the applied delay 1 rather than 2 on both mobile platforms. It is a calibration, not a derivation — it is the number to move first if a device disagrees, and zero restores the uncompensated behaviour.

Two things it does not cover, both accepted:

  • Android costs an extra frame if every mutation goes through runOnUiThread (the WIP approach): the post lands on the next choreographer frame regardless of when it was queued. Instead the Unity thread writes the frame into @Volatile fields and a Choreographer frame callback on the UI thread applies the newest value — no queue latency, no per-frame allocation, and coalescing for free.

  • The renderer swap has the same skew, undelayed. SetVisible and the inner element’s opacity are one exchange between two pipelines, so for the queue’s depth either both renderers draw the text or neither does. Both draw the same face at the same size at the same place, which is what Fonts bought, so the overlap reads as a moment of slightly heavier text and the gap as a moment of none.

Snapping the insets while a field holds the keyboard — removing the ease rather than compensating for it — was tried and reverted for an unrelated reason: it moves the chat in one step, which the message list’s autoscroll does not follow, and a chat that fails to scroll to the newest message is the worse defect. If it is ever wanted again, it is a one-line condition in ApplyInsets plus an AnyFocused query on the registry, and the autoscroll is what needs fixing first.

Visibility and suppression

A native view sits above the entire Unity surface. It has no idea that a popup opened over its field, and — since it is interactive — it would keep eating touches there. Correctness here is an input bug, not a cosmetic one, so it is layered:

  1. Per-element predicate — attached to a panel, non-zero size, display != None, visibility: visible, resolved opacity > 0, enabledInHierarchy.

  2. panel.Pick(centre) returns the field, a descendant, or an ancestor. UI Toolkit’s own hit test answers "is anything on top of me", so a popup, a modal backdrop or a dialog in the same panel suppresses the field for free, with no cooperation from the popup code. Run at most every OcclusionCheckTicks frames, and never while the field is focused: IPanel.Pick validates the panel’s layout, so calling it once per frame per field from LateUpdate forces a layout pass outside UI Toolkit’s own update phase — which dispatches GeometryChangedEvent, which SafeAreaContainer answers by writing margins, which dirties layout again. Occlusion only changes when something opens over the field, so the latency is free and the feedback path is not.

  3. Popups, by event. A popup renders in its own panel above the screen’s, which the screen panel’s hit test cannot see. The registry subscribes to PopupChanged (already published by PopupController, and already in Promethist.Core, so this needs no new assembly reference and no change to the popup code) and marks every field that was already on screen behind it. The marks clear when the popup closes.

    A field inside the popup must be exempt, and "registered before the event" is the wrong test for that: PopupController.ShowNext adds the content to the tree — attaching, and so registering, its fields synchronously — three statements before it flips IsActive. The popup’s own input is already registered by the time the event lands, and marking it leaves the input popup dead: withdrawn, non-interactive, no keyboard, which is exactly how it first shipped. The test is therefore same-frame registration, which means "arrived with this popup" rather than "was here before it". A field registered later still — the next popup in the queue, which raises no second event because IsActive never drops — is never marked at all.

Those three are the whole list. An explicit, ref-counted Suppress() scope for anything else — a decorative picking-mode: ignore overlay, a screen transition — was written and then removed unused: nothing in the app ever opened one. It is roughly fifteen lines (a counter, a check in the predicate, and an IDisposable) the day something genuinely needs it.

Suppression hides the native view and blurs it, and UI Toolkit resumes drawing the text. The same path covers SetEnabled(false), so a disabled field is simply "UI Toolkit renders, greyed, via USS".

The hit test must never read state this system itself changes, or it feeds back. The first implementation blanked the inner element with visibility: hidden and accepted only "the field or a descendant"; on device the pick then stopped resolving to the field, which withdrew the view, which un-blanked the element, which made the pick succeed again — a two-state flicker at exactly the occlusion-check period, with the keyboard dropped on every cycle. Hence both halves of the fix: blanking is opacity, and an ancestor counts as clear, since a parent draws before its children and a pick falling through to one only means our own subtree declined the point.

Two further rules keep the decision from turning into a flicker, because withdrawing a view blurs it and a blur drops the keyboard mid-sentence:

  • A withdrawal must be confirmed by two consecutive frames (HideConfirmTicks); showing stays immediate. One glitchy frame in any predicate can then cost at most a frame of nothing.

  • A field holding the keyboard is never withdrawn by the hit test. It is by definition the thing the user is interacting with, and the hit test is the one predicate above that reads geometry this system itself mutates. Occlusion of a focused field arrives through the explicit paths instead.

DialogController also owns its own panel and publishes no such event, so an error dialog over a field does not withdraw it — the native text stays on top and keeps its touches. Fixing it means publishing a dialog event from Promethist.UI.Popups and marking fields on it the way PopupChanged does, or bringing back the suppression scope described above; neither is done.

Focus and hit-testing

While shown, the native view is interactive and receives the touch directly. This is what buys first-tap caret placement, drag-select and long-press menus — and on Web it is not optional: iOS Safari only raises the keyboard when focus() happens inside the browser’s own gesture handler, which a Unity-side PointerDownEvent (dispatched a frame later, outside the DOM event) can never satisfy. Programmatic focus works fine on iOS and Android, so this rule is uniform only because uniform is simpler, not because all three demand it.

The handshake, guarded by a reentrancy flag on both sides:

  • Native gains focus → FocusChanged(true) → the field calls UI Toolkit Focus(), so :focus USS, focus rings and focusController state are all correct.

  • field.Focus() from code, or UI Toolkit FocusInEvent from any other route → SetFocused(id, true) → native raises the keyboard.

  • Blur from either side propagates the same way.

UI Toolkit must never open its own keyboard, so on device the field runs with textEdition.isReadOnly = true and textEdition.hideSoftKeyboard = true. isReadOnly does not block programmatic value writes, which is exactly what the mirror needs, and hideSoftKeyboard is what TextElement checks before opening a TouchScreenKeyboard behind our back. Today’s hideMobileInput = true and the mobile-native-input tap-catcher child both become unnecessary.

textSelection.isSelectable = false looks like it belongs in that list and must not be set. Its setter also assigns focusable = false on the input element (TextElement.ITextSelection.isSelectable), and Focus() on an unfocusable element takes FocusController.SwitchFocus’s else branch — which releases whatever focus exists instead of granting any. The whole focus mirror then silently does nothing and `:focus USS never applies. It costs nothing to leave selection enabled: a read-only field draws no caret, and on device no pointer event reaches UI Toolkit to start a selection with.

The Web keyboard focus guard (a capture-phase mousedown/touchstart that `preventDefault()`s taps at or below the focused input, so tapping send does not dismiss the keyboard) is unchanged, and still fed by the pushed frame.

One fix falls out of the focus handshake: WebGLInput.captureAllKeyboardInput must be ref-counted on focus, not on visibility as today. Keying it to visibility disables every Unity keyboard shortcut on desktop Web for as long as a chat input is on screen. Focus is both sufficient (DOM focus precedes the first keydown) and necessary (Unity’s capture-phase listener would otherwise preventDefault the keystroke before the input sees it).

Multiline growth

The native view is the only thing that knows where its text wraps, so it is authoritative for height and nothing else:

  1. Native measures the height its text needs at the current frame width — sizeThatFits on iOS, the text Layout’s own height on Android, `scrollHeight on Web — and pushes ContentHeightChanged whenever that value changes. The text alone: each backend excludes its own vertical padding, which is UI Toolkit’s padding pushed down to it and which UI Toolkit adds back around the inner element itself. Reporting it would double the padding on every grown field.

  2. C# converts to panel points and writes style.height on the inner input element.

  3. Normal flex layout grows the field, the row, and whatever else is authored to accommodate it.

  4. The next frame pushes the new frame back down to native.

The loop terminates because content height is a function of width only, and only height is ever written. A half-pixel threshold keeps it from oscillating on rounding.

The chat composer is the growing field. It is authored multiline="true" auto-grow="true" return-key="Submit" — it wraps and grows, but return still sends rather than breaking a line, which keeps Submitted meaning what it meant before and keeps a Send key on the keyboard. Growth therefore comes from wrapping alone; there is no way to type an explicit newline into a message, which is the same trade the field made when it was single-line.

That combination is the one place Android needs care: TYPE_TEXT_FLAG_MULTI_LINE makes the IME replace its action key with a newline key, so a field that wraps and submits asks for neither singleLine nor MULTI_LINE — it turns horizontal scrolling off, which wraps, and keeps the Send action. Return is caught separately by a key listener so nothing inserts a line.

In ConversationStyles.uss the pill’s height became min-height, the row’s buttons moved to its bottom edge (align-items: flex-end, and align-self: flex-end on the send button, which at the resting height is the same 6 px inset centring gave it), .chat__text gained the vertical padding that gives a grown pill its breathing room, and the radius is deliberately unchanged at every height — the usual chat look. The cap is a new token, --chat-input-max-text-height: 120px, five lines of --line-height-larger, applied as a max-height on the inner element.

The cap is in UI Toolkit points, but the lines it caps are drawn by the OS font, whose line height is not --line-height-larger. Five UI Toolkit lines is closer to five and a half system-font lines on a device — the composer stops at the authored height and the native view scrolls, so it is a cosmetic imprecision rather than a bug, but it is why the number cannot be exact on both.

The multimodal input popup stays single-line and does not grow: it prompts for a name or an email, and its return key still submits. AutoGrow following multiline is what makes that the default.

Authoring rules that follow, and that a growing field must respect:

  • The field (and its ancestors) must not have a fixed height. Use min-height for the resting size and max-height for the cap — .chat__input’s `height: var(--control-size) becomes min-height.

  • max-height is the only clamp needed: once the frame is shorter than the content, the native view scrolls its own text, natively.

  • verticalScrollerVisibility stays Hidden, so UI Toolkit does not wrap the input in a ScrollView that would fight the explicit height.

On Android the height must come from TextView.getLayout().height, never from calling measure(). A manual measure() outside a layout pass consumes the view’s force-layout flag, so the parent’s next real traversal treats the view as already measured and skips it: the text wrapped inside a view that stayed one line tall, and only a full window relayout — closing the IME — ever caught up. For the same reason the report is emitted from the per-frame callback and not from afterTextChanged: the Layout is rebuilt by the traversal that follows an edit, so reading it inside the watcher yields the previous text’s height.
On Android, EditText.setInputType must be assigned before setSingleLine, setHorizontallyScrolling and maxLines, never after. It derives its own single-line state from the type (singleLine = !isMultilineInputType(type)) and calls applySingleLine, which resets exactly those three — so a wrap configuration written first is silently undone and the field scrolls sideways instead of wrapping. The one case that needs TYPE_TEXT_FLAG_MULTI_LINE (return inserts a newline) therefore re-applies the wrap properties after setting the type.
On iOS the same trap wears a different hat, and it is the reason the views stay on TextKit 2. A UITextView is a TextKit 2 view (NSTextLayoutManager) on every OS this app supports, and reading the deprecated layoutManager property is the documented trigger that makes it tear that stack down and rebuild a TextKit 1 one. The rebuild happens on the first layout pass — after create has already written the single-line container — and restores widthTracksTextView to YES, so the non-wrapping configuration is silently undone and a long value wraps or truncates instead of scrolling sideways. Used height is therefore measured with NSTextLayoutManager.usageBoundsForTextContainer (after ensureLayoutForRange:, without which it is not accurate), and layoutManager is only reached if something else has already forced the fallback. updateContainer re-asserts the tracking flag alongside every container resize for the same reason.
The non-wrapping container width is large but finite (NonWrappingWidth), not CGFLOAT_MAX. The container width becomes the text view’s own scroll content width, and an infinite content size degenerates caret-following and selection geometry. CGFLOAT_MAX stays correct in sizeThatFits:, which measures rather than laying out.

Single-line horizontal scroll

A non-wrapping container is only half of a single-line field. UITextView follows its caret on the vertical axis only — its scroll-to-selection moves contentOffset.y and nothing in UIKit ever moves x — so a value wider than the box keeps being typed off the right edge, out of the clipped bounds and out of sight. Android needs none of this: setHorizontallyScrolling makes EditText follow the caret on both axes.

scrollCaretIntoView supplies the missing axis. It keeps the end of the selection — the moving edge while typing, and the edge a drag-select extends — inside the padded box, and is called from textViewDidChange (an edit), textViewDidChangeSelection (a tap, an arrow key, a drag-select) and SetText (a programmatic write, which calls no delegate at all). It stands down while the user is dragging the text themselves, and while the field is unfocused; textViewDidEndEditing returns the offset to zero so a blurred field is never parked mid-string.

contentSize.width is pulled back to the text’s own extent, floored at the box width. Without it the text view takes its scrollable content to be NonWrappingWidth wide, and one drag flings the value into empty space it cannot come back from, the bounces being off. The clamp lives in a setContentSize: override so that super’s own write — which happens every layout pass — already lands on the honest width; `layoutSubviews then reconciles once more, because super sized the content earlier in the same pass, before the new extent was known. A half-pixel threshold on that correction is what stops it dirtying layout forever.

The extent is measured from the caret at the end of the document, not from usageBoundsForTextContainer alone (which is only its floor). Glyph bounds stop at the last advance, while the caret stands beyond it, and trailing whitespace is left out of them altogether. Measuring the glyphs alone therefore sets a scroll limit slightly short of the text, and both failures look unrelated: the caret is clamped before it reaches the padded edge, so typing spills out of the box, and the tail of a value stays unreachable however far it is dragged.

In the editor there is no native view, so nothing writes style.height and UI Toolkit’s own measurement grows the field. Same behaviour, different measurer — which means a wrap point can differ by one line between the editor and a device. That is accepted: each renderer is right about itself.

Style transfer

Pushed from resolvedStyle whenever it changes: fontSize, color, unityFontStyleAndWeight, unityTextAlign (both axes), letterSpacing, plus textSelection.cursorColor and selectionColor — which are USS custom properties (--unity-cursor-color, --unity-selection-color), so caret and selection colour stay authorable. Placeholder colour comes from the same place UI Toolkit takes it.

Not transferred, and not supported: rotation and skew (the frame is an axis-aligned box; scale and translation survive because they are baked into worldBound), per-character rich text, and UI Toolkit text outline/shadow. A BetterTextField is plain text by definition.

Fonts

Figtree has to exist as an OS font, not just as a UI Toolkit font asset, or the renderer swap described under Division of responsibility changes the shape of the text every time a popup opens over a field.

The face ships once, as a StreamingAsset at Assets/StreamingAssets/Fonts/Figtree.ttf (62 KB), and each backend finds it where that lands:

  • Android — StreamingAssets is the APK’s asset folder, so Typeface.createFromAsset(assets, "Fonts/Figtree.ttf"), cached on the object and attempted once.

  • iOS — StreamingAssets lands at Data/Raw inside the bundle, so the file is read from there and registered with CTFontManagerRegisterGraphicsFont on first use. The PostScript name is read back off the registered CGFont rather than hardcoded, so replacing the face means replacing the file.

  • Web — nothing to ship: index.html already loads Figtree, and the element names the family explicitly. Not inherit — the page sets no family of its own, so inheriting lands on the browser default. (Self-hosting it in the template would drop the Google Fonts dependency on first paint; still worth doing, still unrelated to this.)

One shared copy rather than one per platform, which is what makes it not a synchronisation hazard: there is no second file to update, no Gradle template entry, and no Info.plist post-processor.

Both native paths fall back to the system face and log if the asset is missing, so a broken build is a slightly wrong-looking field rather than a crash.

Figtree in the project is a variable font, and both platforms take its default instance (weight 400), which is what every input in the app uses. Bold and italic are still requested through the platforms' own trait mechanisms, which may synthesise rather than pick a real cut — if a bold input ever appears and looks wrong, the fix is to ship the static cut for that weight alongside, not to change the design.

Editor and desktop

With no backend compiled in, BetterTextField is a TextField: UI Toolkit renders and edits, isReadOnly/hideSoftKeyboard are never set, growth comes from UI Toolkit’s measurement. The only addition is a trickle-down KeyDownEvent handler that fires Submitted on Enter for single-line fields, so Submitted means the same thing everywhere. This is what the mobile-native-input branch already does and it works.

API surface

Drop-in for the properties the app actually uses, by mapping them instead of reinventing them:

TextField member Behaviour

value, SetValueWithoutNotify, ChangeEvent<string>

Unchanged semantics. Native edits arrive as normal change events; an echo guard prevents feedback.

textEdition.isDelayed

Honoured: with it set, native edits only reach value on submit or blur.

textEdition.placeholder / placeholderText (bindable, as used in ConversationUI.uxml)

Pushed to the native placeholder.

textEdition.keyboardType

Mapped to UIKeyboardType / InputType / DOM type+inputmode.

textEdition.isPassword, maskChar

Mapped to secure entry / TYPE_TEXT_VARIATION_PASSWORD / type=password.

textEdition.maxLength, autoCorrection

Mapped.

textEdition.isReadOnly (author-set)

Native view non-editable but still selectable — real "select and copy" on read-only text.

multiline

Chooses the native view kind and whether Return submits or inserts a newline.

textSelection.selectAllOnFocus

Honoured: the native view selects everything when it takes focus.

Focus(), Blur(), :focus

Work, via the focus handshake.

SetEnabled(false)

Suppresses the native view; UI Toolkit renders the disabled state.

New members: Submitted (as before), ReturnKey, AutoGrow and Autocorrect (all UxmlAttribute`s), and `SelectAllText/SelectTextRange.

Autocorrect (autocorrect in UXML) is this element’s own attribute rather than textEdition.autoCorrection, for two reasons: it defaults to off — welcome in a chat message, a nuisance in a name, an email or a code, and this app’s fields are mostly the latter — and it folds three separate platform settings into one "is this prose" switch, covering autocorrect, spelling suggestions and sentence capitalisation. The standard property is kept in step for anyone who reads it. The chat composer is the one field that asks for it.

On Android both directions have to be stated. Leaving TYPE_TEXT_FLAG_AUTO_CORRECT off is not a request for no autocorrect — the IME keeps offering suggestions and corrections until TYPE_TEXT_FLAG_NO_SUGGESTIONS says otherwise. That asymmetry is why one flag value gave Android autocorrect while iOS, where the "off" value really means off, had none.

ReturnKey is a ReturnKeyModeAuto (submit when single-line, newline when multiline), Newline, or Submit. It is one attribute rather than the separate key-label enum the design sketched, because the label follows from the behaviour: a field that submits asks the OS for a Send key, and one that does not asks for nothing. A Search or Go label can be split out when something needs it.

AutoGrow follows multiline unless authored. Both it and ReturnKey are set explicitly on the chat composer rather than left to their defaults, because whether Unity’s UXML serialiser applies an unauthored attribute (and so overwrites a computed default) was not something this could be tested against — explicit authoring is correct either way.

Selection has to go through SelectAllText/SelectTextRange on the field: textSelection is an interface UI Toolkit hands out directly, so a call on it has nothing this class can intercept.

AutofillHint is not implemented — password-manager AutoFill is still on the list, and nothing in the app has a password field yet.

InputMode/WebInputMode is removed. It duplicates keyboardType + isPassword under a Web-specific name; its two call sites (PromptPresenter, which already sets both) migrate to the standard properties.

Interactions with the rest of the app

  • Liquid glass — a glass-dynamic element samples what is drawn beneath it in its own panel. Native text is composited by the OS above the whole Unity surface, so glass can never refract or blur it. In practice the pill is behind the text and this never comes up; a glass element deliberately placed over an input would show the text unblurred on top of it.

  • SafeAreaContainer — eases its insets as it always did, which is the motion the pushed frame is delayed to stay in step with (see and which way it points). Its ease is exponential at smoothing-speed: 18 — a ~55 ms time constant — measured against the time each step actually covers rather than Time.deltaTime, which is not the same thing: the poller runs on the scheduler’s own 16 ms cadence, so on a 120 Hz display it fires every other frame and easing by one frame’s delta ran it at half speed. On iOS it takes the keyboard height from ScreenBridge_GetKeyboardHeight rather than TouchScreenKeyboard.area. That swap is required, not a refinement: TouchScreenKeyboard.area only reports a keyboard Unity itself opened, and every keyboard in the app is now opened by a native text view instead — so the content never lifted above it. Android and Web need no equivalent, because the visible-frame and visual-viewport measurements they already use see any keyboard regardless of who opened it.

  • Scroll views — see Limitations.

  • ConversationController / PromptPresenter — unchanged apart from dropping InputMode. Both already consume Submitted and value.

Limitations

Limitation Standing

A field inside a ScrollView draws its text outside the viewport, because the native view knows nothing about UI Toolkit clipping.

Open. SetClip was designed and then left out rather than shipped untested: it is a wrapper view with clipsToBounds / clipChildren / clip-path, and no current screen puts an input in a scroll view.

An error dialog does not withdraw a field beneath it.

Open; see the caution under Visibility and suppression.

A drag that starts on a field scrolls the native view’s own text instead of the surrounding UI Toolkit ScrollView.

Accepted. The alternative — a non-interactive native view plus SetCaretFromPoint — costs first-tap caret fidelity and cannot open the keyboard on iOS Safari. Revisit if a scrollable form appears.

Rotated or skewed ancestors mis-place the text.

Accepted; all three platforms could take a 2×3 affine, so it is a phase-3 possibility, not a wall.

Editor and device can wrap at different points.

Accepted, see Multiline growth.

Sub-frame skew while a field is in motion.

Structural, and now compensated to within a frame rather than one to two — see and which way it points. The residue is that the delay is a queue depth Unity asks for, not a latency anyone measured.

Still to do

  • Fonts — the family is the OS default, so every renderer swap is visible. See Fonts.

  • SetClip — a field inside a ScrollView, and the dialog case above.

  • AutoFill hintstextContentType / setAutofillHints, once anything has a password field.

  • Affine frames — all three platforms take a 2×3 transform, which would fix rotated ancestors.

  • Two platform details are accepted as-is: iOS ignores the pushed selectionColor because UIKit derives the selection highlight from tintColor (set from the caret colour), and Web ignores the pushed vertical alignment because an <input> centres its text itself and a growing <textarea> has no slack to align within.

Per-platform manual checklist, since none of this is unit-testable: first-tap caret placement, drag-select, long-press Paste, magnifier, dictation, autocorrect bar, password AutoFill, Return = Send with the keyboard staying up, multiline growth up to max-height then internal scroll, blur by tapping elsewhere, blur by the Android back key, keyboard open/close with the field tracking the pill, a popup opening over the field (renderer swap, no stolen touches), rotation, app backgrounding mid-edit, and — on Web — desktop keyboard shortcuts still reaching Unity while a field is on screen but unfocused.