Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/content/nitro-input-props.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ Alignment of `suffix` only.
Points added after every glyph, like `Text`'s `letterSpacing` (negative tightens). A smaller prefix or suffix gets it in proportion to its size.
</Prop>

<Prop name="tabularNums" type="boolean" default="unset">
Unset: the font's tabular figures, where it has them (the system fonts do), at no cost. `true`: every digit as wide as the widest even in a font without them (Open Runde), laid out that wide and centred, so typing over a `keepPlaceholder` mask never moves what is still to come and an amount keeps its width as its digits change; that layout costs a little on each change. `false`: the font's proportional figures. A style's `fontVariant: ['proportional-nums']` is `false` too, as on a `TextInput`; the prop wins.
</Prop>

<Prop name="prefixSpacing, suffixSpacing" type="number" default="the letter spacing">
Points between the prefix and the text, and between the text and the suffix, in place of the letter spacing there.
</Prop>
Expand Down
55 changes: 55 additions & 0 deletions example/__tests__/nitro-input-sizing.harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const CASES: [string, NitroInputProps][] = [
['maxLength', { defaultValue: 'abcdefghij', maxLength: 4, autoWidth: true, fontSize: 20 }],
['a line height and a weight', { defaultValue: 'Tall', lineHeight: 40, fontWeight: '600', autoWidth: true, fontSize: 22 }],
['a custom font', { fontFamily: 'OpenRunde-Semibold', defaultValue: '$1,250.50', autoWidth: true, fontSize: 16 }],
['a mask in a font without tabular figures', { mode: 'mask', mask: '[0000] [0000]', keepPlaceholder: true, placeholder: '0123 4567', defaultValue: '11', fontFamily: 'OpenRunde-Regular', tabularNums: true, autoWidth: true, fontSize: 20 }],
]

type Rect = { width: number; height: number }
Expand Down Expand Up @@ -216,3 +217,57 @@ describe('a size TextInput would give', () => {
expect(b.height - a.height).toBeCloseTo(8, 0)
})
})

describe('digits in a font without tabular figures', () => {
// Open Runde has proportional digits and no tabular ones to ask for: a "1"
// is far narrower than a "0". With `tabularNums` each digit is laid out as
// wide as the widest, so typing over a kept placeholder never moves what is
// still to come, and an amount keeps its width as its digits change.
for (const transition of ['none', 'reflow'] as const) {
it(`keep a mask's width whatever is typed (${transition})`, async () => {
const ref = createRef<NitroInputHandle>()
const { state, Box } = boxed()
await render(
<Box>
<NitroInput
ref={ref}
transition={transition}
mode="mask"
mask="[0000] [0000] [0000]"
keepPlaceholder
placeholder="0000 0000 0000"
fontFamily="OpenRunde-Regular"
tabularNums
autoWidth
fontSize={22}
/>
</Box>
)
await waitFor(() => expect(state.mounted).not.toBeNull())
await sleep(SETTLE_MS)
const empty = rectOf(state.view!).width
for (const typed of ['1', '1111 1111', '7171 1', '8888 8888 8888', '1111 1111 1111']) {
ref.current!.setText(typed)
await sleep(200)
expect([typed, rectOf(state.view!).width]).toEqual([typed, empty])
}
})
}

for (const [name, tabularNums] of [['unset', undefined], ['false', false]] as const) {
it(`leave each digit its own width with tabularNums ${name}`, async () => {
const ref = createRef<NitroInputHandle>()
const { state, Box } = boxed()
await render(
<Box>
<NitroInput ref={ref} defaultValue="0000" tabularNums={tabularNums} fontFamily="OpenRunde-Regular" autoWidth fontSize={22} />
</Box>
)
await expectFinalAtMount(state)
const zeros = rectOf(state.view!).width
ref.current!.setText('1111')
// Open Runde's "1" is about three quarters of its "0".
await waitFor(() => expect(rectOf(state.view!).width).toBeLessThan(zeros - 8))
})
}
})
16 changes: 16 additions & 0 deletions packages/react-native-nitro-input/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## Unreleased

- **`NitroInput`'s `tabularNums` prop: tabular digits in any font.** Digits
were tabular only in fonts that ship tabular figures: in one without (Open
Runde) a typed "1" was far narrower than the placeholder's "0" it replaced,
so `keepPlaceholder`'s tail slid left on each keystroke (by 14 dp over a
card number), and an amount changed width as its digits did.
`tabularNums` lays every digit out as wide as the widest, centred, as
`NitroNumber`'s columns are: in the field's own text, the placeholder, the
tail and the measure, on both platforms. Unset, the field asks for the
font's tabular figures as before, at no cost; `false` asks for its
proportional ones (as does a style's `fontVariant: ['proportional-nums']`).
- **Android: a plain field's text and placeholder ask for tabular figures.**
The overlay's paints did, the edit text did not, so a font whose tabular
figures are off by default (ABC Diatype) shifted the tail the same way.

## 0.4.0

- **Android: `NitroNumber`'s numeric transition no longer blocks the UI
Expand Down
3 changes: 2 additions & 1 deletion packages/react-native-nitro-input/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ screen behaves like theirs with these settings:
| `affixAlign` | `'baseline' \| 'center' \| 'top' \| 'bottom'` | `'baseline'` | How they line up with the text: `top` pins glyph tops, `bottom` the bottom of the ink (a currency code sits on the digits' baseline). |
| `prefixAlign` / `suffixAlign` | same | `affixAlign` | Per-affix override. |
| `letterSpacing` | number | `0` | Points added after every glyph, like `Text`'s; a smaller affix gets it in proportion to its size. |
| `tabularNums` | boolean | unset | Unset: the font's tabular figures where it has them (the system fonts do), at no cost. `true`: every digit as wide as the widest even in a font without them (Open Runde), laid out that wide and centred, so a `keepPlaceholder` tail never moves and an amount keeps its width as it changes; a little work on each change. `false`: the font's proportional figures, as is a style's `fontVariant: ['proportional-nums']`. |
| `prefixSpacing` / `suffixSpacing` | number | the letter spacing | Points between the prefix and the text, and between the text and the suffix. |
| `prefixOffset` / `suffixOffset` | number | `0` | Points an affix is moved down after its alignment (negative: up). |
| `format` | `NumberFormat` | none | The amount follows it: prefix and suffix, separators, fraction digits, where the sign goes. Sets `mode="number"`; the individual props override it. |
Expand Down Expand Up @@ -587,7 +588,7 @@ screen behaves like theirs with these settings:
| `onKeyPress` | `(event) => void` | – | Before the text changes: the character, `'Backspace'` or `'Enter'`. |
| `onNativeRef` | `(ref) => void` | – | Receives the Nitro object on mount. |
| `id` / `aria-label` | `string` | – | React Native's HTML-style aliases for `nativeID` / `accessibilityLabel`, resolved here because a Nitro view is handed the raw props; each wins over the older spelling. |
| `style`, `testID`, … | `ViewProps`; `style` as `TextInput`'s | – | Regular view props. `testID` and the accessibility label are forwarded to the system field, the element VoiceOver, TalkBack and e2e tools interact with. The field takes its width from its parent like a `TextInput`; see `autoWidth`. As on a `TextInput`, `style` can carry the typography (`fontSize`, `fontWeight`, `fontFamily`, `color`, `letterSpacing`, `lineHeight`, `textAlign`, `textAlignVertical`; the props of the same name win), and its padding and border inset the text and add to a size the field works out for itself. Two deliberate differences: digits are always tabular (Android's Roboto has them anyway, and a field keeps its width whether it reflows or not), and Android gets no default padding (iOS's `TextInput` has none; React Native's Android one keeps the platform field's). |
| `style`, `testID`, … | `ViewProps`; `style` as `TextInput`'s | – | Regular view props. `testID` and the accessibility label are forwarded to the system field, the element VoiceOver, TalkBack and e2e tools interact with. The field takes its width from its parent like a `TextInput`; see `autoWidth`. As on a `TextInput`, `style` can carry the typography (`fontSize`, `fontWeight`, `fontFamily`, `color`, `letterSpacing`, `lineHeight`, `textAlign`, `textAlignVertical`; the props of the same name win), and its padding and border inset the text and add to a size the field works out for itself. Two deliberate differences: digits are tabular by default where the font has tabular figures (Android's Roboto has them anyway, and a field keeps its width whether it reflows or not; see `tabularNums` for a font without them), and Android gets no default padding (iOS's `TextInput` has none; React Native's Android one keeps the platform field's). |

`keyboardType`: `'default' | 'number-pad' | 'decimal-pad' | 'numeric' | 'email-address' | 'phone-pad' | 'url' | 'ascii-capable' | 'numbers-and-punctuation'`.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.margelo.nitro.nitroinput

import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.text.NoCopySpan
import android.text.Spannable
import android.text.TextPaint
import android.text.style.ReplacementSpan
import kotlin.math.ceil

private const val DIGITS = "0123456789"

/**
* Tabular figures for a font that has none.
*
* The paints ask for `tnum`, but a font without the feature (Open Runde) keeps
* its proportional digits: every typed "1" was narrower than the placeholder's
* "0" it replaced, so `keepPlaceholder`'s tail slid left on each keystroke and
* an amount changed width as its digits did. With `tabularNums` every digit
* is given a slot as wide as the widest digit instead, centred in it, as
* NitroNumber's columns are. In a font with tabular figures the digits are
* all that wide already, and nothing changes.
*
* In the edit text a slot is a [ReplacementSpan], whose width is a whole
* pixel, so a slot is the widest digit and one letter spacing rounded up, and
* [InputFonts] measures and draws with that same width. (Letter spacing on the
* digit was the first try: from API 35 the platform trims it at either end of
* a line, so the first and last digit went without.)
*/
internal object DigitSlots {
fun isDigit(codePoint: Int): Boolean = codePoint in '0'.code..'9'.code

/** Each digit's advance in a paint, letter spacing aside, and the widest of them. */
class Digits(val widths: FloatArray) {
val widest = widths.max()

/** A slot in px, for a letter spacing of [spacing] px. */
fun slot(spacing: Float): Int = ceil(widest + spacing - 0.001f).toInt()
}

/**
* The digits of [paint] (its letter spacing aside), or null when they are
* one width already (within a hundredth of the font size).
*/
fun measure(paint: Paint): Digits? {
val bare = Paint(paint)
bare.letterSpacing = 0f
val widths = FloatArray(10) { bare.measureText(DIGITS, it, it + 1) }
if (widths.max() - widths.min() <= paint.textSize * 0.01f) return null
return Digits(widths)
}

private data class Key(val typeface: Typeface?, val size: Float, val features: String?)

// For the edit text's paint, which the spans are handed a copy of on every layout pass.
private val cache = HashMap<Key, Digits?>()

private fun digits(paint: Paint): Digits? = synchronized(cache) {
val key = Key(paint.typeface, paint.textSize, paint.fontFeatureSettings)
if (key in cache) cache[key] else measure(paint).also {
if (cache.size > 32) cache.clear()
cache[key] = it
}
}

private fun spacing(paint: Paint): Float = paint.letterSpacing * paint.textSize

/**
* Re-spans every digit of [text] for [paint] (the edit text's), or only
* clears the spans when its font has tabular figures, or when [off].
*/
fun apply(text: Spannable, paint: Paint, off: Boolean) {
val on = !off && paint.textSize > 0f && digits(paint) != null
// Only what changed: every span set or removed lays the edit text out
// again, and a keystroke adds one digit (the spans of the others move
// with their characters). Re-spanning the lot cost ~1.5 ms a keystroke.
val spanned = BooleanArray(text.length)
for (span in text.getSpans(0, text.length, Slot::class.java)) {
val start = text.getSpanStart(span)
val keep = on && text.getSpanEnd(span) == start + 1 && isDigit(text[start].code) && !spanned[start]
if (keep) spanned[start] = true else text.removeSpan(span)
}
if (!on) return
for (i in text.indices) {
if (!spanned[i] && isDigit(text[i].code)) text.setSpan(Slot(), i, i + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}

/**
* Draws [text] at [x] in [paint] as the spans lay it out in the edit text:
* digits in their slots, the runs between them whole.
*/
fun draw(canvas: Canvas, text: String, x: Float, y: Float, paint: Paint) {
val digits = digits(paint)
if (digits == null) {
canvas.drawText(text, x, y, paint)
return
}
val slot = digits.slot(spacing(paint))
val spacing = paint.letterSpacing
var left = x
var start = 0
while (start < text.length) {
if (isDigit(text[start].code)) {
paint.letterSpacing = 0f
canvas.drawText(text, start, start + 1, left + (slot - digits.widths[text[start] - '0']) / 2f, y, paint)
paint.letterSpacing = spacing
left += slot
start++
continue
}
var end = start + 1
while (end < text.length && !isDigit(text[end].code)) end++
canvas.drawText(text, start, end, left, y, paint)
left += paint.measureText(text, start, end)
start = end
}
}

/**
* One digit, drawn centred in its slot. Not copied with the text (the
* clipboard, a saved state): the watcher puts the spans back on whatever
* text the field ends up with.
*/
private class Slot : ReplacementSpan(), NoCopySpan {
private val bare = TextPaint()

override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
if (fm != null) paint.getFontMetricsInt(fm)
val digits = digits(paint) ?: return ceil(paint.measureText(text, start, end)).toInt()
return digits.slot(spacing(paint))
}

override fun draw(canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
bare.set(paint)
bare.letterSpacing = 0f
val digits = digits(paint)
val inset = if (digits == null) 0f else (digits.slot(spacing(paint)) - digits.widths[text[start] - '0']) / 2f
canvas.drawText(text, start, end, x + inset, y.toFloat(), bare)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ class HybridNitroInputView(private val context: ThemedReactContext) : HybridNitr
set(v) { field = v; markConfigDirty() }
override var textLetterSpacing: Double = 0.0
set(v) { field = v; markConfigDirty() }
override var tabularNums: Boolean = true
set(v) { field = v; markConfigDirty() }
override var slotDigits: Boolean = false
set(v) { field = v; markConfigDirty() }
override var prefixSpacing: Double = Double.POSITIVE_INFINITY
set(v) { field = v; markConfigDirty() }
override var suffixSpacing: Double = Double.POSITIVE_INFINITY
Expand Down Expand Up @@ -423,6 +427,8 @@ class HybridNitroInputView(private val context: ThemedReactContext) : HybridNitr
suffixFontSize = Double.NaN
signPlacement = NitroInputSignPlacement.BEFOREAFFIX
textLetterSpacing = 0.0
tabularNums = true
slotDigits = false
prefixSpacing = Double.POSITIVE_INFINITY
suffixSpacing = Double.POSITIVE_INFINITY
prefixOffset = 0.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,54 @@ internal class InputFonts(
InputRole.SUFFIX -> suffix
}

/** Advance of one character, in px. */
/**
* The body's digits when they are slotted (`tabularNums` set) and the font
* has no tabular figures: each is then laid out in a slot as wide as the
* widest ([DigitSlots]); null otherwise. The affixes keep their own: they
* never change under the typing.
*/
private val bodyDigits: DigitSlots.Digits? = if (t.slotDigits) DigitSlots.measure(body) else null

/** A slotted body digit's advance, its letter spacing aside: the edit text's slot less that spacing. */
private val bodySlot: Float = bodyDigits?.let { it.slot(bodySpacing) - bodySpacing } ?: 0f

private fun slotted(codePoint: Int, role: InputRole): Boolean =
role == InputRole.BODY && bodyDigits != null && DigitSlots.isDigit(codePoint)

/** Advance of one character, in px: a slotted digit's is the slot's. */
fun width(codePoint: Int, role: InputRole): Float =
widthCaches[role.ordinal].getOrPut(codePoint) { paint(role).measureText(charString(codePoint)) }
widthCaches[role.ordinal].getOrPut(codePoint) {
if (slotted(codePoint, role)) bodySlot else paint(role).measureText(charString(codePoint))
}

/** How far to draw a character in from the left of its advance: a slotted digit is centred in it. */
fun inset(codePoint: Int, role: InputRole): Float {
if (!slotted(codePoint, role)) return 0f
return (bodySlot - bodyDigits!!.widths[codePoint - '0'.code]) / 2f
}

/**
* [text] in the body paint as the edit text lays it out, without letter
* spacing: whole, or, when its digits are slotted, in runs split at each
* digit, which is where the slots' spans split it.
*/
fun plainWidth(text: String): Float {
if (bodyDigits == null) return body.measureText(text)
var total = 0f
var start = 0
while (start < text.length) {
if (DigitSlots.isDigit(text[start].code)) {
total += width(text[start].code, InputRole.BODY)
start++
continue
}
var end = start + 1
while (end < text.length && !DigitSlots.isDigit(text[end].code)) end++
total += body.measureText(text, start, end)
start = end
}
return total
}

/** Sum of the per-character advances of [text] (what the engine lays out). */
fun width(text: String, role: InputRole): Float {
Expand Down Expand Up @@ -174,9 +219,9 @@ internal class InputFonts(
paint.typeface = makeTypeface(t)
paint.textSize = sizeDp * density
paint.color = t.color ?: defaultColor
// Tabular figures in every mode, as on iOS: the engine slides digit runs
// in text mode too, and proportional digits would jitter as they change.
paint.fontFeatureSettings = "tnum"
// Tabular figures unless asked not to, as on iOS: the engine slides digit
// runs in text mode too, and proportional digits would jitter as they change.
paint.fontFeatureSettings = if (t.tabularNums) "tnum" else "pnum"
return paint
}

Expand Down
Loading
Loading