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
TouchScreenKeyboardgives 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 |
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 ( |
UI Toolkit |
Native pushes every edit into |
Focus |
Both, mirrored |
Whoever gains focus first tells the other. UI Toolkit focus is kept in sync so |
Font, colour, size, alignment, caret/selection colour |
UI Toolkit, pushed |
Read from |
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 |
|---|---|
|
The |
|
One C# façade over the three backends — the only file with per-platform |
|
|
|
|
|
DOM |
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.
| Call | Notes |
|---|---|
|
|
|
Also drops the listener, so the caller never unregisters anything. |
|
Unity screen pixels, top-left origin. The field’s whole box, padding included. See Coordinates. |
|
Where the text sits inside that frame — |
|
A |
|
Show/hide and interactivity. A hidden view never takes touches. |
|
|
|
Driven by |
|
Programmatic focus, for |
|
Pull path used only after a |
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.
| Event | Notes |
|---|---|
|
Flag only; C# pulls the text. Keeps the callback signature allocation-free on every platform. |
|
Height the text needs at the current frame width. Drives Multiline growth. |
|
Covers every blur source: tapping elsewhere, the Android IME back key, iOS resigning first
responder, a DOM |
|
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 trustingUIScreen.scaleordevicePixelRatio. 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:
-
Evaluate the visibility predicate; on a change,
SetVisibleand flip the inner text element’svisibility. -
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.
-
If the resolved style hash changed, push the style.
-
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@Volatilefields and aChoreographerframe 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.
SetVisibleand the inner element’sopacityare 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:
-
Per-element predicate — attached to a panel, non-zero size,
display != None,visibility: visible, resolvedopacity > 0,enabledInHierarchy. -
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 everyOcclusionCheckTicksframes, and never while the field is focused:IPanel.Pickvalidates the panel’s layout, so calling it once per frame per field fromLateUpdateforces a layout pass outside UI Toolkit’s own update phase — which dispatchesGeometryChangedEvent, whichSafeAreaContaineranswers 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. -
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 byPopupController, and already inPromethist.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.ShowNextadds the content to the tree — attaching, and so registering, its fields synchronously — three statements before it flipsIsActive. 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 becauseIsActivenever 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 ToolkitFocus(), so:focusUSS, focus rings andfocusControllerstate are all correct. -
field.Focus()from code, or UI ToolkitFocusInEventfrom 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:
-
Native measures the height its text needs at the current frame width —
sizeThatFitson iOS, the textLayout’s own height on Android, `scrollHeighton Web — and pushesContentHeightChangedwhenever 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. -
C# converts to panel points and writes
style.heighton the inner input element. -
Normal flex layout grows the field, the row, and whatever else is authored to accommodate it.
-
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. Usemin-heightfor the resting size andmax-heightfor the cap —.chat__input’s `height: var(--control-size)becomesmin-height. -
max-heightis the only clamp needed: once the frame is shorter than the content, the native view scrolls its own text, natively. -
verticalScrollerVisibilitystaysHidden, so UI Toolkit does not wrap the input in aScrollViewthat 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/Rawinside the bundle, so the file is read from there and registered withCTFontManagerRegisterGraphicsFonton first use. The PostScript name is read back off the registeredCGFontrather than hardcoded, so replacing the face means replacing the file. -
Web — nothing to ship:
index.htmlalready loads Figtree, and the element names the family explicitly. Notinherit— 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 |
|---|---|
|
Unchanged semantics. Native edits arrive as normal change events; an echo guard prevents feedback. |
|
Honoured: with it set, native edits only reach |
|
Pushed to the native placeholder. |
|
Mapped to |
|
Mapped to secure entry / |
|
Mapped. |
|
Native view non-editable but still selectable — real "select and copy" on read-only text. |
|
Chooses the native view kind and whether Return submits or inserts a newline. |
|
Honoured: the native view selects everything when it takes focus. |
|
Work, via the focus handshake. |
|
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 ReturnKeyMode — Auto (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-dynamicelement 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 atsmoothing-speed: 18— a ~55 ms time constant — measured against the time each step actually covers rather thanTime.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 fromScreenBridge_GetKeyboardHeightrather thanTouchScreenKeyboard.area. That swap is required, not a refinement:TouchScreenKeyboard.areaonly 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 droppingInputMode. Both already consumeSubmittedandvalue.
Limitations
| Limitation | Standing |
|---|---|
A field inside a |
Open. |
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 |
Accepted. The alternative — a non-interactive native view plus |
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 aScrollView, and the dialog case above. -
AutoFill hints —
textContentType/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
selectionColorbecause UIKit derives the selection highlight fromtintColor(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.