diff --git a/.agent/tools/subset_font.sh b/.agent/tools/subset_font.sh index 73b2850..92d8d52 100755 --- a/.agent/tools/subset_font.sh +++ b/.agent/tools/subset_font.sh @@ -3,8 +3,9 @@ # # The upstream release is ~710 KB per weight, 6.1 MB for the nine — far too much to ship in a # library. This view only ever draws a formatted number, so the subset keeps digits, the separators -# and signs that NumberFormat emits for Latin-script locales, and the "NaN"/"∞" it falls back to. -# That lands at ~15 KB per weight, ~130 KB for all nine. +# and signs that NumberFormat emits for Latin-script locales, the currency symbols and Latin +# letters an ISO currency code needs, and the "NaN"/"∞" it falls back to. The currently committed +# subset is ~33 KB per weight, ~300 KB for all nine. # # Locales whose digits are outside this set (ar-EG, hi-IN, …) are handled at runtime, not here: # NumericTextView checks hasGlyph on the formatted string and falls back to the system typeface @@ -27,7 +28,18 @@ UNICODES="$UNICODES,U+0030-0039" UNICODES="$UNICODES,U+002B,U+002D,U+2212,U+2013" UNICODES="$UNICODES,U+002C,U+002E,U+0027,U+2019,U+00B7,U+066B,U+066C,U+FF0C,U+FF0E" UNICODES="$UNICODES,U+0025,U+2030,U+221E" -UNICODES="$UNICODES,U+0045,U+004E,U+0061" + +# Money. Currency symbols, the whole currency-signs block (€ ₹ ₩ ₪ ₫ ₺ ₽ ₿ and the rest), the +# fullwidth and Arabic forms, and the brackets an accounting format wraps a negative amount in. +UNICODES="$UNICODES,U+0024,U+00A2-00A5,U+0192,U+058F,U+060B,U+07FE-07FF,U+09F2-09F3,U+09FB" +UNICODES="$UNICODES,U+0AF1,U+0BF9,U+0E3F,U+17DB,U+20A0-20C0,U+A838,U+FDFC,U+FE69,U+FF04" +UNICODES="$UNICODES,U+FFE0-FFE1,U+FFE5-FFE6" +UNICODES="$UNICODES,U+0028,U+0029" + +# Letters for `currencyDisplay: 'code'` (`USD 1,234.56`). The committed subset still carries the +# full ASCII alphabet; trimming the unused lowercase range is a packaging optimization and does not +# change the v0.1 formatting contract. +UNICODES="$UNICODES,U+0041-005A,U+0061-007A" WEIGHTS=(Thin ExtraLight Light Regular Medium SemiBold Bold ExtraBold Black) diff --git a/README.md b/README.md index 6763434..d0bbd37 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,11 @@ The platform strategy is intentionally asymmetric: The goal is not to pretend both platforms render text identically. The goal is to give the same React Native component the same class of polished numeric interaction on both platforms. -This project is independent and is not affiliated with Expo or Apple. +The formatting API is a second borrowed idea. [`number-flow`](https://github.com/barvian/number-flow) by Maxwell Barvian solves the same problem on the web, and its answer to "how should a component be told what shape a number takes" is one `format` object shaped like `Intl.NumberFormatOptions` rather than a growing row of flat props. That shape is taken from it directly, including the choice to pass a bound through untouched so `Intl`'s own defaulting rule decides the rest. + +The two libraries reach it from opposite ends: `number-flow` renders real text nodes and lets the browser lay them out, so it inherits `Intl` for free; here the string has to be produced natively on each platform, by `NumberFormatter` and `android.icu`, because the renderers animate the structure of a formatted number rather than a string handed to them. The API is the same either way, which is the point of copying it. + +This project is independent and is not affiliated with Expo, Apple, or `number-flow`. ## Why numeric text needs its own transition model @@ -62,8 +66,11 @@ and it has to keep behaving correctly when updates arrive continuously rather th - Stable interruption and rapid-retarget behaviour. - Continuous increment/decrement updates without resetting the whole animation. - Structural handling of integer digits, fractional digits, grouping separators, decimal separators, and signs. -- Locale-aware native number formatting. -- Configurable grouping and fractional precision. +- Locale-aware native number formatting through an `Intl.NumberFormat`-shaped `format` prop. +- Native currency display with symbol or ISO code, plus the accounting sign for negatives. +- Native percent display. +- Configurable grouping, integer padding, fractional precision, and significant digits. +- Identical rounding on iOS, Android, and web. - Automatic, forced-up, and forced-down transition direction. - System-aware reduced-motion support. - Android 12+ hardware blur path. @@ -121,14 +128,66 @@ Formatting is resolved before the transition is built; separators are not decora ``` This keeps locale-specific punctuation structurally associated with the digits while the value changes. +The `format` prop is a subset of `Intl.NumberFormatOptions`, and each platform resolves it with its own formatter: `NumberFormatter` on iOS, `android.icu` on Android, `Intl` on web. The string is produced where it is drawn, because the renderer animates the structure of a formatted number rather than a string handed to it ready-made. + +The shape of this prop is borrowed from [`number-flow`](https://github.com/barvian/number-flow); see [Origin](#origin). + +`format` is an object, so `NumericText` will re-render whenever the parent does unless you keep it stable. Hoist it to module scope or wrap it in `useMemo` if you rely on the memo skipping renders. + +### Currency + +```tsx + // $1,234.50 + // ¥1,235 + // 1.234,50 € +``` + +`currency` is shorthand for `format={{ style: 'currency', currency }}`. The full form adds how the currency is written and how a negative amount is signed: + +```tsx + +// USD 1,234.50 + + +// ($1,234.50) +``` + +The currency affix takes part in the transition rather than sitting on top of it. It is keyed by its distance from the digits, not by its offset in the string, so `$999` → `$1,000` slides one `$` left instead of destroying it and creating another one, and a trailing `1.234,50 €` keeps its symbol through the same change. Where a locale uses a different decimal mark for money than for plain numbers, the renderers key on the monetary one, so the decimal boundary still holds the fraction digits still. + +Fraction digits follow the currency when you do not set them: two for `USD`, none for `JPY`, three for `BHD`. + +`currencySign: 'accounting'` applies with `currencyDisplay: 'symbol'`. Code display always uses the standard sign form. `Intl`'s `currencyDisplay: 'narrowSymbol'` is not offered, because neither platform's native formatter exposes it at the versions this library supports. + +### Percent, padding, and significant digits + +```tsx + // 42% + // 09 + // 1,230 +``` + +`minimumIntegerDigits` is what holds a clock at `05:09` and stops a counter changing width as it crosses a power of ten. Significant digits take precedence over fraction digits when either bound is set, matching `Intl`. + +### Fractions and rounding + +A decimal mark is structural, not punctuation. Integer digits keep their identity from the left, fraction digits from the decimal mark, and the mark itself holds still, so `9.99` → `10.00` moves the digits it has to and leaves the rest alone. + +Set `minimumFractionDigits` to hold a fixed number of decimals through a change that would otherwise drop one. Without it, `1.50` renders as `1.5` and the fraction columns restructure under a roll that should only have moved digits: + +```tsx + +``` + +### Rounding + +Rounding is half-away-from-zero on both platforms and on web: `2.5` at zero decimals reads as `3` everywhere. That is `Intl`'s default and neither platform's (`NumberFormatter` and ICU both round half-to-even left alone), so it is set explicitly rather than exposed as an option. Two renderers disagreeing about the number they draw is a bug, not a preference. + ## Direction By default, direction follows the numeric change: @@ -162,26 +221,55 @@ During rapid updates, automatic direction is resolved against the value the rend |---|---|---|---| | `value` | `number` | required | Number to display. The first render does not animate. | | `locale` | `string` | `'en-US'` | BCP-47 locale used for native number formatting. | +| `format` | `NumericTextFormat` | `{}` | How to shape the number. See below. | +| `currency` | `string` | none | Shorthand for `format={{ style: 'currency', currency }}`. `format` wins where the two overlap. | | `direction` | `'automatic' \| 'up' \| 'down'` | `'automatic'` | Direction of the numeric transition. | | `animationDuration` | `number` | `80` | Android only. Nominal timing input used to scale the native transition; it is not a hard duration clamp. | | `reduceMotion` | `'system' \| 'always' \| 'never'` | `'system'` | Accessibility behaviour for motion. | -| `useGrouping` | `boolean` | `true` | Enables grouping separators. | -| `minimumFractionDigits` | `number` | `0` | Minimum number of fractional digits. | -| `maximumFractionDigits` | `number` | `3` | Maximum number of fractional digits. | -| `style` | `StyleProp` | — | Text/view style. `fontSize`, `fontWeight`, `fontFamily`, and `color` are forwarded to the native renderer. | -| `testID` | `string` | — | React Native test identifier. | +| `useGrouping` | `boolean` | `true` | Shorthand for the same field of `format`. | +| `minimumFractionDigits` | `number` | none | Shorthand for the same field of `format`. | +| `maximumFractionDigits` | `number` | none | Shorthand for the same field of `format`. | +| `style` | `StyleProp` | none | Text/view style. `fontSize`, `fontWeight`, `fontFamily`, and `color` are forwarded to the native renderer. | +| `testID` | `string` | none | React Native test identifier. | When `fontSize` or `color` are omitted, the native defaults are `48` and black. +### `NumericTextFormat` + +A subset of `Intl.NumberFormatOptions`. Every option is resolved by the platform's own formatter, and means the same thing on both. + +| Option | Type | Default | Description | +|---|---|---|---| +| `style` | `'decimal' \| 'currency' \| 'percent'` | `'decimal'` | `'currency'` needs `currency` and falls back to `'decimal'` without it. `'percent'` multiplies by 100. | +| `currency` | `string` | none | ISO 4217 code. | +| `currencyDisplay` | `'symbol' \| 'code'` | `'symbol'` | `$1,234.56` or `USD 1,234.56`. | +| `currencySign` | `'standard' \| 'accounting'` | `'standard'` | `'accounting'` brackets a negative amount. Applies with `currencyDisplay: 'symbol'`. | +| `useGrouping` | `boolean` | `true` | Enables grouping separators. | +| `minimumIntegerDigits` | `number` | none | Pads with leading zeros to at least this width. | +| `minimumFractionDigits` | `number` | style's own | `0` for a plain number, `0` for a percentage, the currency's count for money. | +| `maximumFractionDigits` | `number` | style's own | `3` for a plain number, `0` for a percentage, the currency's count for money. | +| `minimumSignificantDigits` | `number` | none | Takes precedence over the fraction bounds. | +| `maximumSignificantDigits` | `number` | none | Takes precedence over the fraction bounds. | + +`Intl` options that are **not** supported, and why: + +| Option | Reason | +|---|---| +| `currencyDisplay: 'name'` | Localized names can change spelling with the value (`dollar`/`dollars`); mutable affixes are deferred until they have an explicit transition contract. | +| `notation: 'compact'` (`1.2K`) | Both platforms can produce it, but from different CLDR vintages, so they would disagree on the string for the same input. | +| `signDisplay` | Android's `NumberFormatter` is API 30; iOS's `NumberFormatter` has no equivalent. | +| `currencyDisplay: 'narrowSymbol'` | Same. | +| `unit`, `unitDisplay`, `roundingIncrement`, `roundingMode` | Same, except `roundingMode`, which is fixed at half-away-from-zero on purpose. | + ## Platform behaviour | Platform | Behaviour | |---|---| -| iOS 17+ | Native SwiftUI `.contentTransition(.numericText())`. | +| iOS 17+ | Native SwiftUI `.contentTransition(.numericText())`, formatted by `NumberFormatter`. | | Earlier supported iOS | Native formatting and rendering without the unavailable numeric content transition. | -| Android API 31+ | Native renderer using `RenderNode` and `RenderEffect` for the blur path. | +| Android API 31+ | Native renderer using `RenderNode` and `RenderEffect` for the blur path, formatted by `android.icu`. | | Android API 24-30 | Same transition model with a temporary software-layer blur path while animating. | -| Web | Correctly formatted static `Text`; numeric transitions are not currently animated. | +| Web | Correctly formatted static `Text` via `Intl`; numeric transitions are not currently animated. | The minimum Android SDK is **24**. @@ -193,7 +281,7 @@ The renderer is built around a small set of invariants: 1. **Typeset the complete formatted line first.** Layout and glyph positions come from the full value before the line is partitioned into logical transition slots. 2. **Keep immutable value rasters.** Outgoing content keeps the pixels that belonged to its original formatted value while incoming content references the new target value. -3. **Use structural identity.** Integer digits are anchored from the left, fractional digits from the decimal boundary, and punctuation receives stable semantic identity. +3. **Use structural identity.** Integer digits are anchored from the left, fractional digits from the decimal boundary, and punctuation receives stable semantic identity. A currency affix, a percent sign and an accounting bracket are keyed by their distance from the digits, so they survive the number growing or losing one. 4. **Preserve history during retriggers.** A new target does not require the previous transition to finish first. 5. **Keep frame work bounded.** Expensive bitmap extraction and per-slot bitmap creation stay out of the normal render/update hot path. 6. **Use analytic motion evaluation.** Native transition state can be evaluated directly for the current frame instead of integrating a simulation with frame-rate-dependent state. @@ -235,7 +323,9 @@ Android includes a subset of [Sunghyun Sans](https://github.com/anaclumos/sunghy /> ``` -The bundled Android subset contains Latin-script numeric-formatting glyphs. When a locale requires glyphs unavailable in the bundled face, the renderer falls back to the platform font rather than drawing missing-glyph boxes. +The bundled Android subset contains Latin-script numeric-formatting glyphs: digits, the separators and signs a locale formats with, currency symbols, and the Latin letters needed by ISO currency codes. The current bundled files are about 33 KB a weight, 300 KB for the nine. + +Coverage is checked against the characters the current format will actually draw, so a currency symbol or ISO code is part of the question. When any required glyph is missing from the bundled face, the renderer falls back to the platform font rather than drawing missing-glyph boxes. The full font license is included at `android/src/main/assets/fonts/OFL.txt`. diff --git a/android/src/main/assets/fonts/SunghyunSans-Black.ttf b/android/src/main/assets/fonts/SunghyunSans-Black.ttf index 1b36326..424db24 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Black.ttf and b/android/src/main/assets/fonts/SunghyunSans-Black.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-Bold.ttf b/android/src/main/assets/fonts/SunghyunSans-Bold.ttf index 3ec3357..cae5c9a 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Bold.ttf and b/android/src/main/assets/fonts/SunghyunSans-Bold.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf b/android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf index 5cf3656..b78b887 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf and b/android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf b/android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf index 26bb0e4..a2a7015 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf and b/android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-Light.ttf b/android/src/main/assets/fonts/SunghyunSans-Light.ttf index 62c3857..6010a5e 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Light.ttf and b/android/src/main/assets/fonts/SunghyunSans-Light.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-Medium.ttf b/android/src/main/assets/fonts/SunghyunSans-Medium.ttf index e960092..9de169f 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Medium.ttf and b/android/src/main/assets/fonts/SunghyunSans-Medium.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-Regular.ttf b/android/src/main/assets/fonts/SunghyunSans-Regular.ttf index 4c70990..189e28e 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Regular.ttf and b/android/src/main/assets/fonts/SunghyunSans-Regular.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-SemiBold.ttf b/android/src/main/assets/fonts/SunghyunSans-SemiBold.ttf index 2a33b8f..ce7f517 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-SemiBold.ttf and b/android/src/main/assets/fonts/SunghyunSans-SemiBold.ttf differ diff --git a/android/src/main/assets/fonts/SunghyunSans-Thin.ttf b/android/src/main/assets/fonts/SunghyunSans-Thin.ttf index f2bb8b2..fc69e62 100644 Binary files a/android/src/main/assets/fonts/SunghyunSans-Thin.ttf and b/android/src/main/assets/fonts/SunghyunSans-Thin.ttf differ diff --git a/android/src/main/java/com/numerictext/NumericTextFormatter.kt b/android/src/main/java/com/numerictext/NumericTextFormatter.kt new file mode 100644 index 0000000..0c16d7a --- /dev/null +++ b/android/src/main/java/com/numerictext/NumericTextFormatter.kt @@ -0,0 +1,250 @@ +package com.numerictext + +import android.icu.math.BigDecimal +import android.icu.text.DecimalFormat +import android.icu.text.DecimalFormatSymbols +import android.icu.text.NumberFormat +import android.icu.util.Currency +import java.text.AttributedCharacterIterator +import java.util.Locale + +const val DIGITS_UNSET = -1 + +internal data class NumericFormatSpec( + val locale: String = "en-US", + val numberStyle: String = "decimal", + val currency: String = "", + val currencyDisplay: String = "symbol", + val currencySign: String = "standard", + val useGrouping: Boolean = true, + val minimumIntegerDigits: Int = DIGITS_UNSET, + val minimumFractionDigits: Int = DIGITS_UNSET, + val maximumFractionDigits: Int = DIGITS_UNSET, + val minimumSignificantDigits: Int = DIGITS_UNSET, + val maximumSignificantDigits: Int = DIGITS_UNSET, +) + +internal enum class NumericFieldKind { + INTEGER, + FRACTION, + GROUP_SEPARATOR, + DECIMAL_SEPARATOR, + SIGN, +} + +internal data class NumericSemanticSpan( + val start: Int, + val end: Int, + val kind: NumericFieldKind, +) + +private data class NumericSemanticKey( + val text: String, + val groupingSeparator: Char, + val decimalSeparator: Char, + val minusSign: Char, +) + +/** + * Formats the number and preserves ICU's semantic fields for the transition tokenizer. + * + * Currency affixes are arbitrary localized text and can themselves contain '.', ',' or '-'. ICU's + * `formatToCharacterIterator` labels the actual numeric ranges, so punctuation inside `B/.`, + * `د.إ.` or `US-Dollar` stays an affix instead of stealing DEC/GROUP/SIGN keys. + */ +internal class NumericTextFormatter private constructor( + private val format: NumberFormat, + val groupingSeparator: Char, + val decimalSeparator: Char, + val minusSign: Char, + val glyphProbe: String, +) { + fun format(value: Double): String { + val raw = format.format(value) + semanticSpansFor(value)?.let { remember(raw, it) } + return raw + } + + private fun semanticSpansFor(value: Double): List? = try { + val iterator = format.formatToCharacterIterator(value) + val spans = ArrayList() + var index = iterator.beginIndex + while (index < iterator.endIndex) { + iterator.setIndex(index) + val end = iterator.runLimit + fieldKind(iterator.attributes)?.let { spans.add(NumericSemanticSpan(index, end, it)) } + index = end + } + spans.takeIf { it.any { span -> span.kind == NumericFieldKind.INTEGER || span.kind == NumericFieldKind.FRACTION } } + } catch (_: RuntimeException) { + null + } + + private fun fieldKind( + attributes: Map, + ): NumericFieldKind? = when { + attributes.containsKey(NumberFormat.Field.SIGN) -> NumericFieldKind.SIGN + attributes.containsKey(NumberFormat.Field.DECIMAL_SEPARATOR) -> + NumericFieldKind.DECIMAL_SEPARATOR + attributes.containsKey(NumberFormat.Field.GROUPING_SEPARATOR) -> + NumericFieldKind.GROUP_SEPARATOR + attributes.containsKey(NumberFormat.Field.FRACTION) -> NumericFieldKind.FRACTION + attributes.containsKey(NumberFormat.Field.INTEGER) -> NumericFieldKind.INTEGER + else -> null + } + + private fun remember(text: String, spans: List) { + rememberSemantics( + NumericSemanticKey(text, groupingSeparator, decimalSeparator, minusSign), + spans, + ) + } + + companion object { + private val semantics = LinkedHashMap>(64, 0.75f, true) + + fun semanticSpans( + text: String, + groupingSeparator: Char, + decimalSeparator: Char, + minusSign: Char, + ): List? = synchronized(semantics) { + semantics[NumericSemanticKey(text, groupingSeparator, decimalSeparator, minusSign)] + } + + private fun rememberSemantics( + key: NumericSemanticKey, + spans: List, + ) = synchronized(semantics) { + semantics[key] = spans + while (semantics.size > 128) { + val iterator = semantics.entries.iterator() + if (!iterator.hasNext()) break + iterator.next() + iterator.remove() + } + } + + fun of(spec: NumericFormatSpec): NumericTextFormatter { + val locale = localeOf(spec.locale) + val currency = currencyOf(spec.currency) + val money = spec.numberStyle == "currency" && currency != null + + val format = NumberFormat.getInstance(locale, styleOf(spec, money)) + if (money) format.currency = currency + format.isGroupingUsed = spec.useGrouping + format.roundingMode = BigDecimal.ROUND_HALF_UP + + if (spec.minimumIntegerDigits >= 0) { + format.minimumIntegerDigits = spec.minimumIntegerDigits + } + applyDigitBounds(format, spec, money, currency) + + val symbols = symbolsOf(format, locale) + return NumericTextFormatter( + format = format, + groupingSeparator = + if (money) symbols.monetaryGroupingSeparator else symbols.groupingSeparator, + decimalSeparator = + if (money) symbols.monetaryDecimalSeparator else symbols.decimalSeparator, + minusSign = symbols.minusSign, + glyphProbe = glyphProbeOf(format, symbols), + ) + } + + private fun styleOf(spec: NumericFormatSpec, money: Boolean): Int = when { + !money -> + if (spec.numberStyle == "percent") NumberFormat.PERCENTSTYLE + else NumberFormat.NUMBERSTYLE + spec.currencyDisplay == "code" -> NumberFormat.ISOCURRENCYSTYLE + spec.currencySign == "accounting" -> NumberFormat.ACCOUNTINGCURRENCYSTYLE + else -> NumberFormat.CURRENCYSTYLE + } + + private fun applyDigitBounds( + format: NumberFormat, + spec: NumericFormatSpec, + money: Boolean, + currency: Currency?, + ) { + val decimal = format as? DecimalFormat + if ( + decimal != null && + (spec.minimumSignificantDigits >= 0 || spec.maximumSignificantDigits >= 0) + ) { + val (min, max) = + boundsOf(spec.minimumSignificantDigits, spec.maximumSignificantDigits, 1, 21) + decimal.setSignificantDigitsUsed(true) + decimal.maximumSignificantDigits = max + decimal.minimumSignificantDigits = min + return + } + + val defaultMax = when { + money -> (currency?.defaultFractionDigits ?: 2).coerceAtLeast(0) + spec.numberStyle == "percent" -> 0 + else -> 3 + } + val defaultMin = if (money || spec.numberStyle == "percent") defaultMax else 0 + + val (min, max) = + boundsOf( + spec.minimumFractionDigits, + spec.maximumFractionDigits, + defaultMin, + defaultMax, + ) + format.maximumFractionDigits = max + format.minimumFractionDigits = min + } + + private fun boundsOf( + min: Int, + max: Int, + defaultMin: Int, + defaultMax: Int, + ): Pair = when { + min >= 0 && max >= 0 -> min to maxOf(min, max) + min >= 0 -> min to maxOf(defaultMax, min) + max >= 0 -> minOf(defaultMin, max) to max + else -> defaultMin to defaultMax + } + + private fun symbolsOf(format: NumberFormat, locale: Locale): DecimalFormatSymbols = + (format as? DecimalFormat)?.decimalFormatSymbols + ?: DecimalFormatSymbols.getInstance(locale) + + private fun glyphProbeOf( + format: NumberFormat, + symbols: DecimalFormatSymbols, + ): String = buildString { + val zero = symbols.zeroDigit + for (offset in 0..9) append(zero + offset) + append(symbols.groupingSeparator) + append(symbols.decimalSeparator) + append(symbols.monetaryGroupingSeparator) + append(symbols.monetaryDecimalSeparator) + append(symbols.minusSign) + append(runCatching { format.format(-1234.5) }.getOrDefault("")) + append(runCatching { format.format(0.0) }.getOrDefault("")) + } + + private fun currencyOf(code: String): Currency? { + if (code.isEmpty()) return null + return try { + Currency.getInstance(code) + } catch (_: IllegalArgumentException) { + null + } catch (_: NullPointerException) { + null + } + } + + private fun localeOf(tag: String): Locale = try { + Locale.forLanguageTag(tag.replace("_", "-")).takeIf { it.language.isNotEmpty() } + ?: Locale.US + } catch (_: Exception) { + Locale.US + } + } +} diff --git a/android/src/main/java/com/numerictext/NumericTextTimeline.kt b/android/src/main/java/com/numerictext/NumericTextTimeline.kt index 771c15e..b5473d1 100644 --- a/android/src/main/java/com/numerictext/NumericTextTimeline.kt +++ b/android/src/main/java/com/numerictext/NumericTextTimeline.kt @@ -36,26 +36,17 @@ internal class NumericRollEngine { const val BUILD_ID = "V0.1-RC3-RELEASE-SURFACE-2026-08-11" - // Scale transfers directly. The packed Apple translation value 0.59375 is NOT applied here - // yet: this STACK already has validated reversal-lane geometry, and multiplying that by 0.59375 - // would double-count travel during retriggers. Keep the validated baseline until the exact - // RenderBox translation coordinate space is mapped. private const val STACK_OFFSET = 0.3950f private const val STACK_FINAL_SCALE = 0.3984375f - // RenderBox numericText animation 0: near-critical effect/matchMove spring. private const val APPLE_EFFECT_MASS = 1.0f private const val APPLE_EFFECT_STIFFNESS = 344.0f private const val APPLE_EFFECT_DAMPING = 37.0f - // RenderBox numericText animation 1: underdamped roll/translation spring. private const val APPLE_MOVE_MASS = 2.0f private const val APPLE_MOVE_STIFFNESS = 470.0f private const val APPLE_MOVE_DAMPING = 34.0f - // Presence keeps the validated independent channel. Blur keeps its validated time evolution. - // Phase5d changes only blur amplitude semantics: Apple's packed blur byte 32 is interpreted as - // relative blur 32 / 128 = 0.25, with the View mapping that fraction to text line height. private const val STACK_ALPHA_RESPONSE_SECONDS = 0.277f private const val STACK_ALPHA_DAMPING = 1.00f private const val STACK_EXIT_BLUR_SPEEDUP = 1.35f @@ -77,12 +68,27 @@ internal class NumericRollEngine { private const val WAVE_TOTAL_SECONDS = 0.15f private const val MAX_DURATION_MULTIPLE = 1.25f private const val RESPONSE_SECONDS = 0.30f + // Frame-aligned USD A/B GT: SwiftUI keeps the old line effectively static for roughly + // 70 ms before the per-glyph format wave becomes visually active. Apply the onset to the + // entire unified format wave, never to ordinary same-format numeric changes. + private const val FORMAT_ROLL_ONSET_SECONDS = 0.070f + + // Kept for the legacy structural path. SIGN/OTHER now travel through DIGIT physics, + // so formatGeometrySplit below is the source of truth for unified per-glyph rolls. + private const val AFFIX_DIGIT_LEAD_SECONDS = 0.085f + private const val AFFIX_ENTER_DELAY_SECONDS = 0.050f + private const val AFFIX_PREFIX_REMOVE_DELAY_SECONDS = 0.050f + private const val AFFIX_SUFFIX_REMOVE_DELAY_SECONDS = 0.100f + private const val AFFIX_EXIT_DISTANCE = 0.45f + private const val AFFIX_REPLACEMENT_EXIT_DISTANCE = 1.0f + private const val AFFIX_EXIT_ALPHA_SLOWDOWN = 1.35f private const val POSITION_EPSILON = 0.001f private const val VELOCITY_EPSILON = 0.005f private const val ENTRY_CULL_ALPHA = 0.004f private const val RENDER_ALPHA_EPSILON = 0.01f private const val STRUCTURAL_ENTRY_ALPHA = 0.32f + private const val GEOMETRY_EPSILON_PX = 0.1f } private enum class PendingKind { CHANGE, REMOVE, ENTER } @@ -94,6 +100,8 @@ internal class NumericRollEngine { val dueAtNanos: Long, val kind: PendingKind, val rasterId: Int, + val replacementAffixExit: Boolean = false, + var pinnedX: Float = Float.NaN, ) private class Entry(val ch: String, var p: Float, var rasterId: Int) { @@ -111,6 +119,8 @@ internal class NumericRollEngine { var blurTarget = 0f var superseded = false + var replacementAffixExit = false + var pinnedX = Float.NaN var alpha = 0f var alphaVelocity = 0f @@ -126,18 +136,14 @@ internal class NumericRollEngine { val entries = ArrayList() var target = 0 - var crowdRaw = 0f var crowd = 0f - var flipRaw = 0f var flipGate = 0f var lastDir: Int? = null - var x = 0f var xVelocity = 0f var targetX = 0f - var retiring = false fun goalStop(): Int = pending.lastOrNull()?.stop ?: target @@ -199,7 +205,29 @@ internal class NumericRollEngine { durationScale = animationDurationMs.coerceAtLeast(80L) / 320f lastDirection = if (direction < 0) -1 else 1 - val previousSlots = targetLayout + val requestedFormatChange = hasStructuralFormatChange(targetLayout, layout) + + // A finished format roll can remain internally active for a few frames because invisible ghosts + // are still converging. SwiftUI starts the next settled A/B transition from the canonical target, + // not from those invisible historical entries. Collapse only when the current output is already + // visually identical to the target; genuine in-flight retriggers keep their full history. + if ( + isRunning && + requestedFormatChange && + canCanonicalizeVisibleTarget() + ) { + snapToTarget() + } + + // During an in-flight format retarget, targetLayout can describe glyphs whose delayed wave event + // has never become visible. Do not queue the new request behind that stale future. Drop only + // uncommitted events and reconstruct the previous layout from the entries that actually exist on + // screen; already-committed entries keep their p/q/blur/alpha velocities for a true retrigger. + var previousSlots = targetLayout + if (isRunning && requestedFormatChange) { + previousSlots = rebaseFormatRetargetToVisibleState() + } + val previousByKey = previousSlots.associateBy { it.key } targetText = text @@ -211,18 +239,66 @@ internal class NumericRollEngine { val structuralEnterKeys = HashSet() for (slot in layout) { - if (previousByKey[slot.key] == null) { - structuralEnterKeys.add(slot.key) - } + if (previousByKey[slot.key] == null) structuralEnterKeys.add(slot.key) } val structuralRemovalKeys = HashSet() for (slot in previousSlots) { - if (incomingByKey[slot.key] == null) { - structuralRemovalKeys.add(slot.key) + if (incomingByKey[slot.key] == null) structuralRemovalKeys.add(slot.key) + } + + val formatGeometrySplit = hasStructuralFormatChange(previousSlots, layout) + + val geometryChangeKeys = HashSet() + if (formatGeometrySplit) { + for (slot in layout) { + val previous = previousByKey[slot.key] ?: continue + if (abs(xRel(previous) - xRel(slot)) > GEOMETRY_EPSILON_PX) { + geometryChangeKeys.add(slot.key) + } + } + + for (column in columns.values) { + for (entry in column.entries) { + if (entry.pinnedX.isNaN()) entry.pinnedX = column.x + } + } + } else { + // Pins belong only to a structural format roll. As soon as the new request resolves to an + // ordinary/current-layout update, release every active entry from its pin. Superseded ghosts + // keep their own historical X, so this restores first-release reflow without moving old glyphs. + for ((key, column) in columns) { + if (!previousByKey.containsKey(key) || !incomingByKey.containsKey(key)) continue + + val currentPinned = + column.entries.lastOrNull { + !it.superseded && !it.pinnedX.isNaN() + } + if (currentPinned != null) { + column.x = currentPinned.pinnedX + column.xVelocity = 0f + } + + for (entry in column.entries) { + if (!entry.superseded) { + entry.pinnedX = Float.NaN + } + } + for (pending in column.pending) { + if (pending.kind != PendingKind.REMOVE) { + pending.pinnedX = Float.NaN + } + } } } + val hasAffixEnter = + layout.any { structuralEnterKeys.contains(it.key) && isAffixKind(it.kind) } + val hasAffixRemoval = + previousSlots.any { structuralRemovalKeys.contains(it.key) && isAffixKind(it.kind) } + val affixTopologyChanged = hasAffixEnter || hasAffixRemoval + val affixReplacement = hasAffixEnter && hasAffixRemoval + for ((key, column) in columns) { column.retiring = incomingByKey[key] == null } @@ -234,8 +310,11 @@ internal class NumericRollEngine { val column = columns[slot.key] if ( - structuralEnterKeys.contains(slot.key) || + formatGeometrySplit || + structuralEnterKeys.contains(slot.key) || + geometryChangeKeys.contains(slot.key) || column == null || + column.entries.none { !it.superseded } || stopFor(column, slot, lastDirection) != column.goalStop() ) { digitEventKeys.add(slot.key) @@ -257,18 +336,29 @@ internal class NumericRollEngine { kind: PendingKind, wavePhase: Int, sourceRasterId: Int, + replacementAffixExit: Boolean = false, + pinnedX: Float = Float.NaN, ) { - val phase = - if (changingCount > 0) wavePhase.coerceIn(0, changingCount - 1) else 0 - + val phase = if (changingCount > 0) wavePhase.coerceIn(0, changingCount - 1) else 0 val waveDelayNanos = - ( - gap.toDouble() * - (phase + 0.5) * - 1_000_000_000.0 - ).toLong() + (gap.toDouble() * (phase + 0.5) * 1_000_000_000.0).toLong() + val formatOnsetNanos = + if (formatGeometrySplit) { + (FORMAT_ROLL_ONSET_SECONDS.toDouble() * 1_000_000_000.0).toLong() + } else { + 0L + } + val digitLeadNanos = + if (affixTopologyChanged && column.kind == TokenKind.DIGIT) { + (AFFIX_DIGIT_LEAD_SECONDS.toDouble() * 1_000_000_000.0).toLong() + } else { + 0L + } + val affixDelayNanos = + (affixDelaySeconds(column, kind).toDouble() * 1_000_000_000.0).toLong() - val requestedDueNanos = eventNanos + waveDelayNanos + val requestedDueNanos = + eventNanos + formatOnsetNanos + digitLeadNanos + affixDelayNanos + waveDelayNanos val previousPending = column.pending.lastOrNull() val dueAtNanos = @@ -277,11 +367,7 @@ internal class NumericRollEngine { } else { val arrivalSpacingNanos = (eventNanos - previousPending.enqueuedAtNanos).coerceAtLeast(1L) - - maxOf( - requestedDueNanos, - previousPending.dueAtNanos + arrivalSpacingNanos, - ) + maxOf(requestedDueNanos, previousPending.dueAtNanos + arrivalSpacingNanos) } column.pending.addLast( @@ -292,6 +378,8 @@ internal class NumericRollEngine { dueAtNanos = dueAtNanos, kind = kind, rasterId = sourceRasterId, + replacementAffixExit = replacementAffixExit, + pinnedX = pinnedX, ) ) } @@ -300,34 +388,57 @@ internal class NumericRollEngine { val oldPhaseByKey = wavePhases(previousSlots, digitEventKeys, changingCount) for (slot in layout) { + val incomingX = xRel(slot) var column = columns[slot.key] if (column == null) { column = Column(slot.key, slot.kind) - column.x = xRel(slot) + column.x = incomingX column.targetX = column.x columns[slot.key] = column } column.kind = slot.kind - column.targetX = xRel(slot) + column.targetX = incomingX column.retiring = false if (structuralEnterKeys.contains(slot.key)) { - val stop = column.goalStop() - column.charAt[stop] = slot.char - - enqueue( - column = column, - stop = stop, - kind = PendingKind.ENTER, - wavePhase = newPhaseByKey[slot.key] ?: 0, - sourceRasterId = rasterId, - ) + val needsRevive = column.entries.none { !it.superseded } + if (needsRevive && column.entries.isNotEmpty()) { + val next = column.goalStop() - lastDirection + column.charAt[next] = slot.char + enqueue( + column = column, + stop = next, + kind = PendingKind.CHANGE, + wavePhase = newPhaseByKey[slot.key] ?: 0, + sourceRasterId = rasterId, + pinnedX = if (formatGeometrySplit) incomingX else Float.NaN, + ) + } else { + val stop = column.goalStop() + column.charAt[stop] = slot.char + enqueue( + column = column, + stop = stop, + kind = PendingKind.ENTER, + wavePhase = newPhaseByKey[slot.key] ?: 0, + sourceRasterId = rasterId, + pinnedX = if (formatGeometrySplit) incomingX else Float.NaN, + ) + } continue } - val next = stopFor(column, slot, lastDirection) + val ordinaryNext = stopFor(column, slot, lastDirection) + val needsRevive = column.entries.none { !it.superseded } + val next = + if ((formatGeometrySplit || needsRevive) && ordinaryNext == column.goalStop()) { + column.goalStop() - lastDirection + } else { + ordinaryNext + } + if (next != column.goalStop()) { column.charAt[next] = slot.char @@ -337,6 +448,7 @@ internal class NumericRollEngine { kind = PendingKind.CHANGE, wavePhase = newPhaseByKey[slot.key] ?: 0, sourceRasterId = rasterId, + pinnedX = if (formatGeometrySplit) incomingX else Float.NaN, ) } else { bindCurrentRaster(column, slot.char, rasterId) @@ -353,6 +465,7 @@ internal class NumericRollEngine { kind = PendingKind.REMOVE, wavePhase = oldPhaseByKey[slot.key] ?: 0, sourceRasterId = -1, + replacementAffixExit = affixReplacement && isAffixKind(column.kind), ) } @@ -431,7 +544,6 @@ internal class NumericRollEngine { val pendingStop = column.pending.removeFirst() val stop = pendingStop.stop val commitDir = pendingStop.direction - val wasAtRest = column.entries.all { abs(it.p) < POSITION_EPSILON && abs(it.velocity) < VELOCITY_EPSILON @@ -439,9 +551,7 @@ internal class NumericRollEngine { column.target = stop - if (!wasAtRest) { - column.crowdRaw = min(1f, column.crowdRaw + CROWD_STEP) - } + if (!wasAtRest) column.crowdRaw = min(1f, column.crowdRaw + CROWD_STEP) val oldDir = column.lastDir if (oldDir != null && oldDir != commitDir && !wasAtRest) { @@ -450,10 +560,20 @@ internal class NumericRollEngine { column.lastDir = commitDir when (pendingStop.kind) { - PendingKind.REMOVE -> commitRemove(column, commitDir) - PendingKind.ENTER -> commitEnter(column, stop, commitDir, pendingStop.rasterId) + PendingKind.REMOVE -> + commitRemove(column, commitDir, pendingStop.replacementAffixExit) + PendingKind.ENTER -> + commitEnter(column, stop, commitDir, pendingStop.rasterId, pendingStop.pinnedX) PendingKind.CHANGE -> - commitChange(column, stop, commitDir, oldDir, wasAtRest, pendingStop.rasterId) + commitChange( + column, + stop, + commitDir, + oldDir, + wasAtRest, + pendingStop.rasterId, + pendingStop.pinnedX, + ) } active = true @@ -474,12 +594,8 @@ internal class NumericRollEngine { val ids = LinkedHashSet() if (targetRasterId > 0) ids.add(targetRasterId) for (column in columns.values) { - for (entry in column.entries) { - if (entry.rasterId > 0) ids.add(entry.rasterId) - } - for (pending in column.pending) { - if (pending.rasterId > 0) ids.add(pending.rasterId) - } + for (entry in column.entries) if (entry.rasterId > 0) ids.add(entry.rasterId) + for (pending in column.pending) if (pending.rasterId > 0) ids.add(pending.rasterId) } return ids } @@ -490,18 +606,29 @@ internal class NumericRollEngine { return out } - private fun commitRemove(column: Column, direction: Int) { + private fun commitRemove( + column: Column, + direction: Int, + replacementAffixExit: Boolean, + ) { for (entry in column.entries) { if (!entry.superseded) { - supersede(entry, direction) + supersede(entry, direction, column.kind, replacementAffixExit) } } } - private fun commitEnter(column: Column, stop: Int, direction: Int, rasterId: Int) { + private fun commitEnter( + column: Column, + stop: Int, + direction: Int, + rasterId: Int, + pinnedX: Float, + ) { val ch = column.charAt[stop] ?: return val entry = Entry(ch, incomingAmplitude(direction, column.flipRaw), rasterId) entry.alpha = STRUCTURAL_ENTRY_ALPHA + entry.pinnedX = pinnedX column.entries.add(entry) } @@ -512,20 +639,16 @@ internal class NumericRollEngine { oldDirection: Int?, wasAtRest: Boolean, rasterId: Int, + pinnedX: Float, ) { val ch = column.charAt[stop] ?: return + val reversing = oldDirection != null && oldDirection != direction && !wasAtRest - for (entry in column.entries) { - if (!entry.superseded) { - supersede(entry, direction) - } - } - - val reversing = - oldDirection != null && - oldDirection != direction && - !wasAtRest - + // Capture a true historical candidate before superseding the currently active entry. When a + // structural format roll forces U -> U (or any unchanged glyph) and the direction reverses, + // searching after supersede() would select the entry we just marked as outgoing and immediately + // reactivate it at target 0. That cancels the roll after the first A/B cycle. Ordinary numeric + // reversals keep the same behavior because their returning glyph already exists as an older ghost. val reuse = if (reversing) { column.entries.lastOrNull { it.superseded && it.ch == ch } @@ -533,20 +656,27 @@ internal class NumericRollEngine { null } + for (entry in column.entries) { + if (!entry.superseded) { + supersede(entry, direction, column.kind, replacementAffixExit = false) + } + } + if (reuse != null) { reuse.superseded = false + reuse.replacementAffixExit = false reuse.target = 0f reuse.posTarget = 0f reuse.alphaTarget = 1f reuse.blurTarget = 0f reuse.rasterId = rasterId + reuse.pinnedX = pinnedX return } val entry = Entry(ch, incomingAmplitude(direction, column.flipRaw), rasterId) - if (column.entries.isEmpty()) { - entry.alpha = STRUCTURAL_ENTRY_ALPHA - } + entry.pinnedX = pinnedX + if (column.entries.isEmpty()) entry.alpha = STRUCTURAL_ENTRY_ALPHA column.entries.add(entry) } @@ -555,17 +685,28 @@ internal class NumericRollEngine { current.rasterId = rasterId } - private fun supersede(entry: Entry, direction: Int) { + private fun supersede( + entry: Entry, + direction: Int, + kind: TokenKind, + replacementAffixExit: Boolean, + ) { + val isReplacement = replacementAffixExit && isAffixKind(kind) + val exitDistance = when { + isReplacement -> AFFIX_REPLACEMENT_EXIT_DISTANCE + isAffixKind(kind) -> AFFIX_EXIT_DISTANCE + else -> 1f + } entry.superseded = true - entry.target = direction.toFloat() - entry.posTarget = direction.toFloat() + entry.replacementAffixExit = isReplacement + entry.target = direction.toFloat() * exitDistance + entry.posTarget = direction.toFloat() * exitDistance entry.blurTarget = 1f entry.alphaTarget = 0f } private fun incomingAmplitude(direction: Int, flipRaw: Float): Float = - -direction.toFloat() * - (1f + (STACK_FLIP_BORN - 1f) * flipRaw) + -direction.toFloat() * (1f + (STACK_FLIP_BORN - 1f) * flipRaw) private fun emitStack(column: Column, out: MutableList) { val count = column.entries.size @@ -580,8 +721,7 @@ internal class NumericRollEngine { total += presence } - val norm = - if (total > STACK_ALPHA_CEILING) STACK_ALPHA_CEILING / total else 1f + val norm = if (total > STACK_ALPHA_CEILING) STACK_ALPHA_CEILING / total else 1f for (i in 0 until count) { val alpha = raw[i] * norm @@ -589,12 +729,10 @@ internal class NumericRollEngine { val entry = column.entries[i] val distance = min(1f, abs(entry.q)) - val hasOtherVisibleEntry = column.entries.indices.any { j -> j != i && column.entries[j].alpha > RENDER_ALPHA_EPSILON } - val settled = i == count - 1 && !hasOtherVisibleEntry && @@ -624,17 +762,13 @@ internal class NumericRollEngine { }, renderId = entry.id, rasterId = entry.rasterId, - x = column.x, + x = if (entry.pinnedX.isNaN()) column.x else entry.pinnedX, offsetY = effectiveOffset * lineHeightPx, alpha = alpha.coerceIn(0f, 1f), scaleX = shrink, scaleY = shrink, blurLengthPx = - if (settled) { - 0f - } else { - maxBlurLengthPx * entry.b.coerceIn(0f, 1f) - }, + if (settled) 0f else maxBlurLengthPx * entry.b.coerceIn(0f, 1f), stable = settled, ) ) @@ -648,6 +782,138 @@ internal class NumericRollEngine { return from - direction } + private fun isAffixKind(kind: TokenKind): Boolean = + kind == TokenKind.SIGN || kind == TokenKind.OTHER + + private fun hasStructuralFormatChange( + previous: List, + incoming: List, + ): Boolean { + val previousByKey = previous.associateBy { it.key } + val incomingByKey = incoming.associateBy { it.key } + + return incoming.any { + previousByKey[it.key] == null && isAffixKind(it.semanticKind) + } || + previous.any { + incomingByKey[it.key] == null && isAffixKind(it.semanticKind) + } || + incoming.any { slot -> + val old = previousByKey[slot.key] + old != null && + (isAffixKind(old.semanticKind) || isAffixKind(slot.semanticKind)) && + old.char != slot.char + } + } + + private fun canCanonicalizeVisibleTarget(): Boolean { + if (columns.values.any { it.pending.isNotEmpty() }) return false + + val targetByKey = targetLayout.associateBy { it.key } + val visible = samples() + if (visible.size != targetByKey.size) return false + + if ( + visible.any { sample -> + val slot = targetByKey[sample.key] + slot == null || + slot.char != sample.ch || + !sample.stable || + sample.alpha < 1f - RENDER_ALPHA_EPSILON + } + ) { + return false + } + + return columns.values.all { column -> + column.retiring || + (abs(column.targetX - column.x) <= 0.1f && abs(column.xVelocity) <= 0.1f) + } + } + + private fun rebaseFormatRetargetToVisibleState(): List { + val visibleSlots = ArrayList(columns.size) + val iterator = columns.entries.iterator() + + while (iterator.hasNext()) { + val column = iterator.next().value + + // Pending stops have not affected a frame yet, so they must not survive a format retarget. + column.pending.clear() + column.entries.removeAll { + it.superseded && it.alpha <= RENDER_ALPHA_EPSILON + } + + val active = column.entries.lastOrNull { !it.superseded } + val outgoingGhost = + if (active == null) { + column.entries.maxByOrNull { it.alpha } + ?.takeIf { it.alpha > RENDER_ALPHA_EPSILON } + } else { + null + } + val current = active ?: outgoingGhost + + if (current == null) { + iterator.remove() + continue + } + + // Once the queue is discarded, target is the last committed stop. Remove stale chars belonging + // to future stops so stopFor() can only reason from the glyph state that actually reached screen. + column.charAt.keys.retainAll(setOf(column.target)) + column.charAt[column.target] = current.ch + + // A fading EXIT remains in the physical column so a reversal can reuse its velocity, blur and + // alpha. It is not, however, part of the active text topology. Counting it as a previous slot + // makes a returning sign look structurally unchanged and suppresses the unified format wave for + // stable affix glyphs such as U/S/D on the second positive -> negative USD-code transition. + if (active == null) continue + + val visibleX = if (active.pinnedX.isNaN()) column.x else active.pinnedX + val semanticKind = semanticKindForKey(column.key, column.kind) + visibleSlots.add( + KeyedSlot( + key = column.key, + kind = column.kind, + semanticKind = semanticKind, + char = active.ch, + centerFromLeft = visibleX, + totalWidth = 0f, + leftFromLeft = visibleX, + rightFromLeft = visibleX, + utf16Start = 0, + utf16End = active.ch.length, + ) + ) + } + + return visibleSlots.sortedBy { it.centerFromLeft } + } + + private fun semanticKindForKey(key: String, physicalKind: TokenKind): TokenKind = + when { + key == "S" -> TokenKind.SIGN + key.startsWith("P") || key.startsWith("X") -> TokenKind.OTHER + key.startsWith("DEC:") -> TokenKind.DECIMAL_SEPARATOR + key.startsWith("G") -> TokenKind.GROUP_SEPARATOR + else -> physicalKind + } + + private fun isSuffixAffix(column: Column): Boolean = + column.kind == TokenKind.OTHER && column.key.startsWith("X") + + private fun affixDelaySeconds(column: Column, kind: PendingKind): Float { + if (!isAffixKind(column.kind)) return 0f + return when (kind) { + PendingKind.ENTER -> AFFIX_ENTER_DELAY_SECONDS + PendingKind.REMOVE -> + if (isSuffixAffix(column)) AFFIX_SUFFIX_REMOVE_DELAY_SECONDS + else AFFIX_PREFIX_REMOVE_DELAY_SECONDS + PendingKind.CHANGE -> 0f + } + } + private fun wavePhases( layout: List, digitEventKeys: Set, @@ -660,12 +926,10 @@ internal class NumericRollEngine { for (slot in layout) { if (slot.kind == TokenKind.DIGIT && digitEventKeys.contains(slot.key)) { - phases[slot.key] = - if (changingCount > 0) phase.coerceAtMost(changingCount - 1) else 0 + phases[slot.key] = if (changingCount > 0) phase.coerceAtMost(changingCount - 1) else 0 phase += 1 } else { - phases[slot.key] = - if (changingCount > 0) phase.coerceAtMost(changingCount - 1) else 0 + phases[slot.key] = if (changingCount > 0) phase.coerceAtMost(changingCount - 1) else 0 } } @@ -682,27 +946,14 @@ internal class NumericRollEngine { val oneWayCrowd = column.crowd * column.crowdRaw val reversalSuppression = (1f - column.flipRaw).coerceIn(0f, 1f) - val geometryRush = - 1f + STACK_CROWD_SPEEDUP * oneWayCrowd * reversalSuppression - + val geometryRush = 1f + STACK_CROWD_SPEEDUP * oneWayCrowd * reversalSuppression val base = response / RESPONSE_SECONDS - val alphaClock = - ( - 2.0 * Math.PI / - max(0.05f, STACK_ALPHA_RESPONSE_SECONDS * base) - ).toFloat() - + (2.0 * Math.PI / max(0.05f, STACK_ALPHA_RESPONSE_SECONDS * base)).toFloat() val blurClockBase = - ( - 2.0 * Math.PI / - max(0.05f, STACK_BLUR_RESPONSE_SECONDS * base) - ).toFloat() + (2.0 * Math.PI / max(0.05f, STACK_BLUR_RESPONSE_SECONDS * base)).toFloat() for (entry in column.entries) { - // Use Apple's actual physical roll spring, solved analytically in O(1). This preserves the - // improvement from phase5 without its fixed-substep CPU cost and without changing the - // historical event stream that makes continuous hold/retrigger work. val moveScale = appleTimeScale / geometryRush val moveResult = integrateSpringExact( value = entry.p, @@ -727,46 +978,38 @@ internal class NumericRollEngine { moving = true } - // Blur deliberately remains its own validated channel. Tying blur to add/remove presence in - // phase5 made the visible part of a continuous roll nearly sharp. val blurClock = if (entry.superseded) blurClockBase * STACK_EXIT_BLUR_SPEEDUP else blurClockBase val bError = entry.blurTarget - entry.b if (abs(bError) > POSITION_EPSILON || abs(entry.bVelocity) > VELOCITY_EPSILON) { entry.bVelocity += - ( - (blurClock * blurClock * bError) - - (2f * STACK_BLUR_DAMPING * blurClock * entry.bVelocity) - ) * dt - + ((blurClock * blurClock * bError) - + (2f * STACK_BLUR_DAMPING * blurClock * entry.bVelocity)) * dt entry.b = (entry.b + entry.bVelocity * dt).coerceIn(0f, 1f) - if ( (entry.b <= 0f && entry.bVelocity < 0f) || (entry.b >= 1f && entry.bVelocity > 0f) ) { entry.bVelocity = 0f } - moving = true } else { entry.b = entry.blurTarget entry.bVelocity = 0f } - // Presence also remains independent until we can map RenderBox insertion/removal composition - // exactly. This keeps the validated overlap normalisation and the "all digits dance" retrigger. val aError = entry.alphaTarget - entry.alpha if (abs(aError) > POSITION_EPSILON || abs(entry.alphaVelocity) > VELOCITY_EPSILON) { + val affixExitSlowdown = + if (entry.superseded && isAffixKind(column.kind)) AFFIX_EXIT_ALPHA_SLOWDOWN else 1f val localAlphaClock = - alphaClock / (1f + (STACK_FLIP_SOFT - 1f) * column.flipRaw) + alphaClock / + ((1f + (STACK_FLIP_SOFT - 1f) * column.flipRaw) * affixExitSlowdown) entry.alphaVelocity += - ( - (localAlphaClock * localAlphaClock * aError) - - (2f * STACK_ALPHA_DAMPING * localAlphaClock * entry.alphaVelocity) - ) * dt + ((localAlphaClock * localAlphaClock * aError) - + (2f * STACK_ALPHA_DAMPING * localAlphaClock * entry.alphaVelocity)) * dt entry.alpha += entry.alphaVelocity * dt moving = true } else { @@ -774,8 +1017,6 @@ internal class NumericRollEngine { entry.alphaVelocity = 0f } - // Scale uses Apple's near-critical effect spring, again solved analytically. q keeps its own - // state so size is not forced to share alpha or blur progress. val effectScale = appleTimeScale / geometryRush val scaleResult = integrateSpringExact( value = entry.q, @@ -811,7 +1052,6 @@ internal class NumericRollEngine { private data class SpringResult(val value: Float, val velocity: Float) - /** Exact solution of m*x'' + c*x' + k*(x-target) = 0 for a constant target over [dt]. */ private fun integrateSpringExact( value: Float, velocity: Float, @@ -826,7 +1066,6 @@ internal class NumericRollEngine { val h = dt.toDouble() if (h <= 0.0) return SpringResult(value, velocity) - // Scaling time by S is equivalent to k/S^2 and c/S, preserving damping ratio. val m = mass.toDouble() val k = stiffness.toDouble() / (scale * scale) val c = damping.toDouble() / scale @@ -849,10 +1088,8 @@ internal class NumericRollEngine { val sinT = sin(wd * h) val shape = a * cosT + b * sinT y = decay * shape - v = decay * ( - -zeta * omega0 * shape + - (-a * wd * sinT + b * wd * cosT) - ) + v = decay * + (-zeta * omega0 * shape + (-a * wd * sinT + b * wd * cosT)) } zeta > 1.0 + 1e-6 -> { @@ -922,4 +1159,4 @@ internal class NumericRollEngine { private fun xRel(slot: KeyedSlot): Float = slot.centerFromLeft - slot.totalWidth / 2f -} \ No newline at end of file +} diff --git a/android/src/main/java/com/numerictext/NumericTextTypesetter.kt b/android/src/main/java/com/numerictext/NumericTextTypesetter.kt index 2098abd..35ac8d0 100644 --- a/android/src/main/java/com/numerictext/NumericTextTypesetter.kt +++ b/android/src/main/java/com/numerictext/NumericTextTypesetter.kt @@ -1,6 +1,8 @@ package com.numerictext import android.graphics.Color +import android.graphics.Path +import android.graphics.RectF import android.text.Layout import android.text.StaticLayout import android.text.TextPaint @@ -18,6 +20,38 @@ data class TextLineGeometry( ) { fun horizontalAt(utf16Offset: Int): Float = horizontals[utf16Offset.coerceIn(0, horizontals.lastIndex)] + + /** + * Returns the visual cell occupied by one logical text range. + * + * A bidi boundary can have two valid caret positions. `getPrimaryHorizontal(start/end)` chooses + * only one of them, so pairing those two carets can cut directly through the glyph next to an + * RTL/LTR boundary. Android's selection path is range-aware and follows the shaped visual run, + * which is exactly the geometry the raster partition needs. + */ + fun visualBounds(utf16Start: Int, utf16End: Int): Pair { + val start = utf16Start.coerceIn(0, text.length) + val end = utf16End.coerceIn(start, text.length) + val shaped = layout + + if (shaped != null && end > start) { + val path = Path() + shaped.getSelectionPath(start, end, path) + val bounds = RectF() + path.computeBounds(bounds, true) + + val left = bounds.left - horizontalOrigin + val right = bounds.right - horizontalOrigin + if (left.isFinite() && right.isFinite() && right > left) { + return left to right + } + } + + // JVM tests and zero-width control characters do not have a useful selection rectangle. + val a = horizontalAt(start) + val b = horizontalAt(end) + return min(a, b) to max(a, b) + } } /** diff --git a/android/src/main/java/com/numerictext/NumericTextView.kt b/android/src/main/java/com/numerictext/NumericTextView.kt index f2392d6..696e213 100644 --- a/android/src/main/java/com/numerictext/NumericTextView.kt +++ b/android/src/main/java/com/numerictext/NumericTextView.kt @@ -21,13 +21,11 @@ import android.text.TextPaint import android.view.Choreographer import android.view.View import com.facebook.react.common.assets.ReactFontManager -import java.text.DecimalFormatSymbols -import java.text.NumberFormat -import java.util.Locale import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max import kotlin.math.roundToInt + /** * Android numericText renderer. The complete formatted line is shaped and rasterized once, then * persistent STACK entries animate keyed slices of that immutable raster. @@ -50,13 +48,13 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall private var settledText: String = "0" private var targetText: String = "0" private var hasSettledOnce = false + private var formatTransitionPending = false + + /** Everything about the shape of the number, as `src/numberFormat.ts` resolved it. */ + private var formatSpec = NumericFormatSpec() - var numericLocale: String = "en-US"; private set var numericDirection: String = "automatic"; private set var animationDurationMs: Long = 320L; private set - var numericUseGrouping: Boolean = true; private set - var numericMinFractionDigits: Int = 0; private set - var numericMaxFractionDigits: Int = 3; private set var numericReduceMotion: String = "system"; private set var numericFontSize: Float = 48f; private set var numericFontWeight: String = "normal"; private set @@ -86,6 +84,17 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall val minus: Char, ) + /** + * Two formats can key the same characters differently: a comma is a grouping mark in `en-US` + * and a decimal mark in `de-DE`. The marks in force are therefore part of the cache key. + */ + private fun preparedKeyFor(text: String) = PreparedKey( + text, + formatter.groupingSeparator, + formatter.decimalSeparator, + formatter.minusSign, + ) + private data class PreparedText( val layout: List, val raster: NumericTextRaster, @@ -103,16 +112,11 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall private val gaussianEffectCache = HashMap(24) // Formatter - private var formatter: NumberFormat? = null - private var currentFormatterLocale: Locale? = null - private var currentGroupSep: Char = ',' - private var currentDecimalSep: Char = '.' - private var currentMinusSign: Char = '-' + private var formatter: NumericTextFormatter = NumericTextFormatter.of(formatSpec) init { clipToOutline = true recalcTextPaint() - recalcFormatter() } private fun hHeadroom(): Float = textHeightPx * 0.36f + 4f @@ -124,27 +128,24 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall ceil(lineWidthOf(text) + 2f * hHeadroom() + paddingLeft + paddingRight).toInt() override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val h = textHeightPx - val contentWidth = if (engine.isRunning) max(measureOldWidth, measureNewWidth) - else lineWidthOf(settledText.ifEmpty { "0" }) - - val dw = ceil(contentWidth + 2f * hHeadroom() + paddingLeft + paddingRight).toInt() - - val vHeadroom = h * 1.2f - val dh = ceil(h + 2f * vHeadroom + paddingTop + paddingBottom).toInt() - - setMeasuredDimension( - resolveSize(maxOf(dw, suggestedMinimumWidth), widthMeasureSpec), - resolveSize(maxOf(dh, suggestedMinimumHeight), heightMeasureSpec), - ) -} + val h = textHeightPx + val contentWidth = if (engine.isRunning) max(measureOldWidth, measureNewWidth) + else lineWidthOf(settledText.ifEmpty { "0" }) + val dw = ceil(contentWidth + 2f * hHeadroom() + paddingLeft + paddingRight).toInt() + val vHeadroom = h * 1.2f + val dh = ceil(h + 2f * vHeadroom + paddingTop + paddingBottom).toInt() + + setMeasuredDimension( + resolveSize(maxOf(dw, suggestedMinimumWidth), widthMeasureSpec), + resolveSize(maxOf(dh, suggestedMinimumHeight), heightMeasureSpec), + ) + } override fun onAttachedToWindow() { super.onAttachedToWindow() NumericTextFrameRecorder.configure(this) - recalcFormatter() if (engine.isRunning) { beginAnimationRenderPath() postFrame() @@ -156,6 +157,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall endAnimationRenderPath() super.onDetachedFromWindow() } + private var edgeFadeGradient: LinearGradient? = null private var edgeFadeMaskPaint: Paint? = null private val softwareBlurCache = HashMap() @@ -360,7 +362,6 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall canvas.drawRenderNode(node) } - @SuppressLint("NewApi") private fun effectFor(lengthPx: Float): RenderEffect? { if (lengthPx < BLUR_MIN_PX) return null @@ -432,14 +433,18 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall private fun startOrRetarget() { val formatted = formatNumber(numericValue) - if (formatted == targetText && engine.isRunning) return - if (formatted == settledText && !engine.isRunning) return + if (!formatTransitionPending) { + if (formatted == targetText && engine.isRunning) return + if (formatted == settledText && !engine.isRunning) return + } val next = preparedTextOf(formatted) - val oldWidth = if (engine.isRunning) engine.targetWidth() else lineWidthOf(settledText) + val oldWidth = + if (engine.isRunning || formatTransitionPending) engine.targetWidth() + else lineWidthOf(settledText) val direction = resolveDirection(numericValue, if (engine.isRunning) targetValue else settledValue) - if (!engine.isRunning && targetText == settledText) { + if (!engine.isRunning && targetText == settledText && !formatTransitionPending) { val current = preparedTextOf(settledText) engine.reset(current.layout, settledText, textHeightPx, current.raster.id, appleBlurLengthPx()) } @@ -454,6 +459,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall next.layout, formatted, direction, textHeightPx, animationDurationMs, next.raster.id, appleBlurLengthPx(), ) + formatTransitionPending = false engine.snapToTarget() finishMotion() return @@ -463,6 +469,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall next.layout, formatted, direction, textHeightPx, animationDurationMs, next.raster.id, appleBlurLengthPx(), ) + formatTransitionPending = false beginAnimationRenderPath() prunePreparedTextCache() updateContentDescription() @@ -519,41 +526,46 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall } } - private fun recalcFormatter() { - formatter = null - currentFormatterLocale = null + /** + * Rebuilds the formatter after a formatting prop changed, and re-checks the typeface with it. + * + * During a simultaneous format + value transaction the old raster must remain addressable until + * the new target has been installed. A formatter may change the selected typeface (for example + * when moving into an Arabic currency), so that preservation has to survive recalcTextPaint too. + */ + private fun recalcFormatter(preservePreparedRasters: Boolean = false) { + formatter = NumericTextFormatter.of(formatSpec) + if (textPaint.typeface != resolveTypeface()) { + recalcTextPaint(preservePreparedRasters) + } } - private fun formatNumber(value: Double): String { - val locale = resolveLocale() - if (formatter == null || currentFormatterLocale != locale) { - formatter = NumberFormat.getNumberInstance(locale) - currentFormatterLocale = locale - val symbols = DecimalFormatSymbols.getInstance(locale) - currentGroupSep = symbols.groupingSeparator - currentDecimalSep = symbols.decimalSeparator - currentMinusSign = symbols.minusSign - } - formatter?.let { - it.isGroupingUsed = numericUseGrouping - it.minimumFractionDigits = numericMinFractionDigits - it.maximumFractionDigits = numericMaxFractionDigits + private fun formatNumber(value: Double): String = formatter.format(value) + + internal fun setFormatSpec(value: NumericFormatSpec, deferReformat: Boolean) { + if (value == formatSpec) return + formatSpec = value + recalcFormatter(preservePreparedRasters = deferReformat) + + if (deferReformat) { + // Key lookups from now on belong to the final formatter, while preparedById still owns the + // immutable old raster referenced by the engine. + preparedByKey.clear() + formatTransitionPending = true + } else { + formatTransitionPending = false + reformatAtRest() } - return formatter?.format(value) ?: value.toString() } - private fun resolveLocale(): Locale = try { - Locale.forLanguageTag(numericLocale.replace("_", "-")) - } catch (_: Exception) { - val parts = numericLocale.split("-", "_") - when (parts.size) { - 1 -> Locale(parts[0]) - 2 -> Locale(parts[0], parts[1]) - else -> Locale.US - } + /** Applies [change] to the formatting props, and reformats if it changed anything. */ + private fun updateFormat(change: (NumericFormatSpec) -> NumericFormatSpec) { + val next = change(formatSpec) + if (next == formatSpec) return + setFormatSpec(next, deferReformat = false) } - private fun recalcTextPaint() { + private fun recalcTextPaint(preservePreparedRasters: Boolean = false) { textPaint.color = numericTextColor textPaint.textSize = numericFontSize * resources.displayMetrics.scaledDensity textPaint.isAntiAlias = true @@ -565,7 +577,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall fmDescent = metrics.descent textHeightPx = metrics.descent - metrics.ascent lineGeometryCache.clear() - clearPreparedTextCache() + if (preservePreparedRasters) preparedByKey.clear() else clearPreparedTextCache() paintGeneration++ edgeFadeGradient = null edgeFadeMaskPaint = null @@ -578,15 +590,15 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall } private fun preparedTextOf(text: String): PreparedText { - val key = PreparedKey(text, currentGroupSep, currentDecimalSep, currentMinusSign) + val key = preparedKeyFor(text) preparedByKey[key]?.let { return it } val line = lineGeometryOf(text) val layout = TransitionLogic.layoutKeyedSlots( text, - currentGroupSep, - currentDecimalSep, - currentMinusSign, + formatter.groupingSeparator, + formatter.decimalSeparator, + formatter.minusSign, line, ) val raster = NumericTextRasterizer.rasterize( @@ -608,18 +620,21 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall } private fun prunePreparedTextCache() { - if (preparedByKey.size <= RASTER_CACHE_TARGET) return - val referenced = engine.referencedRasterIds() - val iterator = preparedByKey.entries.iterator() - while (preparedByKey.size > RASTER_CACHE_TARGET && iterator.hasNext()) { - val entry = iterator.next() - val id = entry.value.raster.id - if (id !in referenced) { - preparedById.remove(id) - iterator.remove() + + if (preparedByKey.size > RASTER_CACHE_TARGET) { + val iterator = preparedByKey.entries.iterator() + while (preparedByKey.size > RASTER_CACHE_TARGET && iterator.hasNext()) { + val entry = iterator.next() + val id = entry.value.raster.id + if (id !in referenced) iterator.remove() } } + + val keepIds = HashSet(preparedByKey.size + referenced.size) + for (prepared in preparedByKey.values) keepIds.add(prepared.raster.id) + keepIds.addAll(referenced) + preparedById.keys.retainAll(keepIds) } private fun lineGeometryOf(text: String): TextLineGeometry { @@ -655,22 +670,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall } val bundled = NumericTextFonts.bundled(context.assets, weight) ?: return system val probe = TextPaint(textPaint).apply { typeface = bundled } - return if (NumericTextFonts.canRender(probe, localeGlyphProbe())) bundled else system - } - - private fun localeGlyphProbe(): String { - val symbols = try { - DecimalFormatSymbols.getInstance(resolveLocale()) - } catch (_: Exception) { - return "0123456789,.-" - } - val zero = symbols.zeroDigit - return buildString { - for (i in 0..9) append(zero + i) - append(symbols.groupingSeparator) - append(symbols.decimalSeparator) - append(symbols.minusSign) - } + return if (NumericTextFonts.canRender(probe, formatter.glyphProbe)) bundled else system } private fun baselineY(centerY: Float): Float = centerY + textHeightPx / 2f - fmDescent @@ -683,6 +683,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall settledText = formatNumber(value) targetText = settledText hasSettledOnce = true + formatTransitionPending = false val prepared = preparedTextOf(settledText) engine.reset(prepared.layout, settledText, textHeightPx, prepared.raster.id, appleBlurLengthPx()) updateContentDescription() @@ -698,33 +699,27 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall fun setAnimationDuration(value: Double) { animationDurationMs = value.toLong().coerceAtLeast(80L) } fun setReduceMotion(value: String) { numericReduceMotion = value } - fun setLocale(value: String) { - if (value == numericLocale) return - numericLocale = value - recalcFormatter() - reformatAtRest() - } + fun setLocale(value: String) = updateFormat { it.copy(locale = value) } + fun setNumberStyle(value: String) = updateFormat { it.copy(numberStyle = value) } + fun setCurrency(value: String) = updateFormat { it.copy(currency = value) } + fun setCurrencyDisplay(value: String) = updateFormat { it.copy(currencyDisplay = value) } + fun setCurrencySign(value: String) = updateFormat { it.copy(currencySign = value) } + fun setUseGrouping(value: Boolean) = updateFormat { it.copy(useGrouping = value) } - fun setUseGrouping(value: Boolean) { - if (value == numericUseGrouping) return - numericUseGrouping = value - recalcFormatter() - reformatAtRest() - } + fun setMinimumIntegerDigits(value: Int) = + updateFormat { it.copy(minimumIntegerDigits = value) } - fun setMinimumFractionDigits(value: Int) { - if (value == numericMinFractionDigits) return - numericMinFractionDigits = value - recalcFormatter() - reformatAtRest() - } + fun setMinimumFractionDigits(value: Int) = + updateFormat { it.copy(minimumFractionDigits = value) } - fun setMaximumFractionDigits(value: Int) { - if (value == numericMaxFractionDigits) return - numericMaxFractionDigits = value - recalcFormatter() - reformatAtRest() - } + fun setMaximumFractionDigits(value: Int) = + updateFormat { it.copy(maximumFractionDigits = value) } + + fun setMinimumSignificantDigits(value: Int) = + updateFormat { it.copy(minimumSignificantDigits = value) } + + fun setMaximumSignificantDigits(value: Int) = + updateFormat { it.copy(maximumSignificantDigits = value) } private fun reformatAtRest() { if (engine.isRunning) { @@ -744,6 +739,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall val next = value.coerceAtLeast(4f) if (next == numericFontSize) return numericFontSize = next + formatTransitionPending = false recalcTextPaint() val prepared = preparedTextOf(targetText) engine.reset(prepared.layout, targetText, textHeightPx, prepared.raster.id, appleBlurLengthPx()) @@ -754,6 +750,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall fun setFontWeight(value: String) { if (value == numericFontWeight) return numericFontWeight = value + formatTransitionPending = false recalcTextPaint() val prepared = preparedTextOf(targetText) engine.reset(prepared.layout, targetText, textHeightPx, prepared.raster.id, appleBlurLengthPx()) @@ -764,6 +761,7 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall fun setFontFamily(value: String) { if (value == numericFontFamily) return numericFontFamily = value + formatTransitionPending = false recalcTextPaint() val prepared = preparedTextOf(targetText) engine.reset(prepared.layout, targetText, textHeightPx, prepared.raster.id, appleBlurLengthPx()) @@ -802,4 +800,4 @@ class NumericTextView(context: Context) : View(context), Choreographer.FrameCall */ private const val BLUR_STEPS_PER_PX = 8f } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/numerictext/NumericTextViewManager.kt b/android/src/main/java/com/numerictext/NumericTextViewManager.kt index a1850b6..478940f 100644 --- a/android/src/main/java/com/numerictext/NumericTextViewManager.kt +++ b/android/src/main/java/com/numerictext/NumericTextViewManager.kt @@ -1,17 +1,22 @@ package com.numerictext +import com.facebook.react.R import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.SimpleViewManager import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.annotations.ReactProp -import com.facebook.react.viewmanagers.NumericTextViewManagerInterface import com.facebook.react.viewmanagers.NumericTextViewManagerDelegate +import com.facebook.react.viewmanagers.NumericTextViewManagerInterface +import java.util.WeakHashMap @ReactModule(name = NumericTextViewManager.NAME) class NumericTextViewManager : SimpleViewManager(), NumericTextViewManagerInterface { private val mDelegate: ViewManagerDelegate + private val pendingByView = WeakHashMap() + private val committedFormatByView = WeakHashMap() + private val committedValueByView = WeakHashMap() init { mDelegate = NumericTextViewManagerDelegate(this) @@ -25,66 +30,192 @@ class NumericTextViewManager : SimpleViewManager(), return NumericTextView(context) } + /** + * React may deliver every changed prop through a separate setter, but a formatter is one logical + * value. Stage the whole transaction, resolve one final NumericFormatSpec, and hand that complete + * spec to the View before installing the final numeric target. + * + * NumericTextView keeps the previous immutable raster alive across that formatter swap, so the + * engine transitions directly from the old rendered string to the final rendered string. There is + * no intermediate "old value interpreted by new formatter" baseline and no opportunity for a + * half-old currency/locale to enter the persistent stack. + */ + private fun pending(view: NumericTextView?): PendingProps? { + if (view == null) return null + return pendingByView.getOrPut(view) { PendingProps() } + } + @ReactProp(name = "value") override fun setValue(view: NumericTextView?, value: Double) { - view?.setValue(value) + pending(view)?.value = value } @ReactProp(name = "direction") override fun setDirection(view: NumericTextView?, direction: String?) { - view?.setDirection(direction ?: "automatic") + pending(view)?.direction = direction ?: "automatic" } @ReactProp(name = "locale") override fun setLocale(view: NumericTextView?, locale: String?) { - view?.setLocale(locale ?: "en-US") + pending(view)?.locale = locale ?: "en-US" } @ReactProp(name = "animationDuration") override fun setAnimationDuration(view: NumericTextView?, value: Double) { - view?.setAnimationDuration(value) + pending(view)?.animationDuration = value + } + + @ReactProp(name = "reduceMotion") + override fun setReduceMotion(view: NumericTextView?, mode: String?) { + pending(view)?.reduceMotion = mode ?: "system" + } + + @ReactProp(name = "numberStyle") + override fun setNumberStyle(view: NumericTextView?, value: String?) { + pending(view)?.numberStyle = value ?: "decimal" + } + + @ReactProp(name = "currency") + override fun setCurrency(view: NumericTextView?, value: String?) { + pending(view)?.currency = value ?: "" + } + + @ReactProp(name = "currencyDisplay") + override fun setCurrencyDisplay(view: NumericTextView?, value: String?) { + pending(view)?.currencyDisplay = value ?: "symbol" + } + + @ReactProp(name = "currencySign") + override fun setCurrencySign(view: NumericTextView?, value: String?) { + pending(view)?.currencySign = value ?: "standard" } @ReactProp(name = "useGrouping") override fun setUseGrouping(view: NumericTextView?, value: Boolean) { - view?.setUseGrouping(value) + pending(view)?.useGrouping = value + } + + @ReactProp(name = "minimumIntegerDigits") + override fun setMinimumIntegerDigits(view: NumericTextView?, value: Int) { + pending(view)?.minimumIntegerDigits = value } @ReactProp(name = "minimumFractionDigits") override fun setMinimumFractionDigits(view: NumericTextView?, value: Int) { - view?.setMinimumFractionDigits(value) + pending(view)?.minimumFractionDigits = value } @ReactProp(name = "maximumFractionDigits") override fun setMaximumFractionDigits(view: NumericTextView?, value: Int) { - view?.setMaximumFractionDigits(value) + pending(view)?.maximumFractionDigits = value } - @ReactProp(name = "reduceMotion") - override fun setReduceMotion(view: NumericTextView?, mode: String?) { - view?.setReduceMotion(mode ?: "system") + @ReactProp(name = "minimumSignificantDigits") + override fun setMinimumSignificantDigits(view: NumericTextView?, value: Int) { + pending(view)?.minimumSignificantDigits = value + } + + @ReactProp(name = "maximumSignificantDigits") + override fun setMaximumSignificantDigits(view: NumericTextView?, value: Int) { + pending(view)?.maximumSignificantDigits = value } @ReactProp(name = "fontSize") override fun setFontSize(view: NumericTextView?, value: Float) { - view?.setFontSize(value) + pending(view)?.fontSize = value } @ReactProp(name = "fontWeight") override fun setFontWeight(view: NumericTextView?, weight: String?) { - view?.setFontWeight(weight ?: "normal") + pending(view)?.fontWeight = weight ?: "normal" } @ReactProp(name = "fontFamily") override fun setFontFamily(view: NumericTextView?, family: String?) { - view?.setFontFamily(family ?: NumericTextFonts.BUNDLED) + pending(view)?.fontFamily = family ?: NumericTextFonts.BUNDLED } @ReactProp(name = "textColor") override fun setTextColor(view: NumericTextView?, color: Int?) { - view?.setTextColor(color ?: android.graphics.Color.BLACK) + pending(view)?.textColor = color ?: android.graphics.Color.BLACK + } + + override fun onAfterUpdateTransaction(view: NumericTextView) { + super.onAfterUpdateTransaction(view) + val props = pendingByView.remove(view) ?: return + val previousFormat = committedFormatByView[view] ?: NumericFormatSpec() + val finalFormat = props.resolveFormat(previousFormat) + val formatChanged = finalFormat != previousFormat + val previousValue = committedValueByView[view] + val finalValue = props.value ?: previousValue + + props.direction?.let(view::setDirection) + props.animationDuration?.let(view::setAnimationDuration) + props.reduceMotion?.let(view::setReduceMotion) + props.fontSize?.let(view::setFontSize) + props.fontWeight?.let(view::setFontWeight) + props.fontFamily?.let(view::setFontFamily) + props.textColor?.let(view::setTextColor) + + if (formatChanged) { + val transitionValue = finalValue + view.setFormatSpec(finalFormat, deferReformat = transitionValue != null) + transitionValue?.let(view::setValue) + } else { + props.value?.let(view::setValue) + } + + committedFormatByView[view] = finalFormat + finalValue?.let { committedValueByView[view] = it } + + // NumericTextView supplies the formatted number as its default contentDescription. BaseViewManager + // stores an explicit React Native accessibilityLabel in this tag; a value/format update happens + // at the end of the transaction and must not overwrite that consumer-provided label. + (view.getTag(R.id.accessibility_label) as? String)?.let { view.contentDescription = it } } + override fun onDropViewInstance(view: NumericTextView) { + pendingByView.remove(view) + committedFormatByView.remove(view) + committedValueByView.remove(view) + super.onDropViewInstance(view) + } + + private data class PendingProps( + var value: Double? = null, + var direction: String? = null, + var locale: String? = null, + var animationDuration: Double? = null, + var reduceMotion: String? = null, + var numberStyle: String? = null, + var currency: String? = null, + var currencyDisplay: String? = null, + var currencySign: String? = null, + var useGrouping: Boolean? = null, + var minimumIntegerDigits: Int? = null, + var minimumFractionDigits: Int? = null, + var maximumFractionDigits: Int? = null, + var minimumSignificantDigits: Int? = null, + var maximumSignificantDigits: Int? = null, + var fontSize: Float? = null, + var fontWeight: String? = null, + var fontFamily: String? = null, + var textColor: Int? = null, + ) { + fun resolveFormat(base: NumericFormatSpec): NumericFormatSpec = base.copy( + locale = locale ?: base.locale, + numberStyle = numberStyle ?: base.numberStyle, + currency = currency ?: base.currency, + currencyDisplay = currencyDisplay ?: base.currencyDisplay, + currencySign = currencySign ?: base.currencySign, + useGrouping = useGrouping ?: base.useGrouping, + minimumIntegerDigits = minimumIntegerDigits ?: base.minimumIntegerDigits, + minimumFractionDigits = minimumFractionDigits ?: base.minimumFractionDigits, + maximumFractionDigits = maximumFractionDigits ?: base.maximumFractionDigits, + minimumSignificantDigits = minimumSignificantDigits ?: base.minimumSignificantDigits, + maximumSignificantDigits = maximumSignificantDigits ?: base.maximumSignificantDigits, + ) + } companion object { const val NAME = "NumericTextView" diff --git a/android/src/main/java/com/numerictext/TransitionLogic.kt b/android/src/main/java/com/numerictext/TransitionLogic.kt index 42f6082..c62c56a 100644 --- a/android/src/main/java/com/numerictext/TransitionLogic.kt +++ b/android/src/main/java/com/numerictext/TransitionLogic.kt @@ -11,6 +11,7 @@ enum class TokenKind { data class KeyedSlot( val key: String, val kind: TokenKind, + val semanticKind: TokenKind = kind, val char: String, val centerFromLeft: Float, val totalWidth: Float, @@ -21,32 +22,105 @@ data class KeyedSlot( ) object TransitionLogic { + private data class RawToken( + val text: String, + val codePoint: Int, + val utf16Start: Int, + val utf16End: Int, + ) + private data class Token( val text: String, val kind: TokenKind, + val fractional: Boolean, val utf16Start: Int, val utf16End: Int, ) - /** Integer digits keep visual identity from the left; fractions from the decimal point. */ - fun layoutKeyedSlots( + /** + * Integer digits keep logical identity from the left; fractions from the decimal point. Affix + * identity, however, is derived from shaped visual geometry rather than logical string order. + * That distinction matters for bidi formats: an AED affix can live after the digits logically but + * render to their left. Treating that token as a suffix would incorrectly match it with a former + * visual suffix and make the column travel horizontally across the number. + * + * ICU semantic fields remain the source of truth for token meaning. Visible SIGN/OTHER glyphs are + * emitted through the DIGIT physics path so currency affixes, signs and suffixes use the exact + * validated roll, movement, scale, blur, alpha and wave machinery as numeric digits. Numeric + * separators deliberately keep their first-release physics classification. Directional bidi marks + * stay in the shaped line but do not become transition slots because they have no visible glyph. + * + * Returned slots are ordered by visual X. NumericRollEngine's existing wave therefore traverses the + * shaped line in screen order, including RTL affixes, without changing any animation constants. + */ + internal fun layoutKeyedSlots( formatted: String, groupSep: Char, decimalSep: Char, minusSign: Char, line: TextLineGeometry, + semanticSpans: List? = null, ): List { require(line.text == formatted) { "TextLineGeometry must belong to the formatted string" } - val tokens = tokenize(formatted, groupSep, decimalSep, minusSign) - val decimalIndex = tokens.indexOfFirst { it.kind == TokenKind.DECIMAL_SEPARATOR } - val integerEnd = if (decimalIndex >= 0) decimalIndex else tokens.size + val semantics = semanticSpans ?: NumericTextFormatter.semanticSpans( + formatted, + groupSep, + decimalSep, + minusSign, + ) + val tokens = + ( + if (semantics != null) tokenizeSemantically(formatted, semantics) + else tokenizeFallback(formatted, groupSep, decimalSep, minusSign) + ).filterNot(::isDirectionalToken) + + if (tokens.isEmpty()) return emptyList() + + val firstDigit = tokens.indexOfFirst { it.kind == TokenKind.DIGIT } + val lastDigit = tokens.indexOfLast { it.kind == TokenKind.DIGIT } val integerDigitsToRight = IntArray(tokens.size) var digitsToRight = 0 - for (i in integerEnd - 1 downTo 0) { + for (i in tokens.indices.reversed()) { integerDigitsToRight[i] = digitsToRight - if (tokens[i].kind == TokenKind.DIGIT) digitsToRight += 1 + if (tokens[i].kind == TokenKind.DIGIT && !tokens[i].fractional) digitsToRight += 1 + } + + val lefts = FloatArray(tokens.size) + val rights = FloatArray(tokens.size) + val centers = FloatArray(tokens.size) + for (i in tokens.indices) { + val (left, right) = line.visualBounds(tokens[i].utf16Start, tokens[i].utf16End) + lefts[i] = left + rights[i] = right + centers[i] = (left + right) / 2f + } + + val digitIndices = tokens.indices.filter { tokens[it].kind == TokenKind.DIGIT } + val numericLeft = digitIndices.minOfOrNull { lefts[it] } + val numericRight = digitIndices.maxOfOrNull { rights[it] } + val visualEpsilon = 0.001f + + val prefixRank = IntArray(tokens.size) { -1 } + val suffixRank = IntArray(tokens.size) { -1 } + + if (numericLeft != null && numericRight != null) { + tokens.indices + .filter { + tokens[it].kind == TokenKind.OTHER && + rights[it] <= numericLeft + visualEpsilon + } + .sortedByDescending { centers[it] } + .forEachIndexed { rank, tokenIndex -> prefixRank[tokenIndex] = rank } + + tokens.indices + .filter { + tokens[it].kind == TokenKind.OTHER && + lefts[it] >= numericRight - visualEpsilon + } + .sortedBy { centers[it] } + .forEachIndexed { rank, tokenIndex -> suffixRank[tokenIndex] = rank } } val result = ArrayList(tokens.size) @@ -55,69 +129,175 @@ object TransitionLogic { for (i in tokens.indices) { val token = tokens[i] - val a = line.horizontalAt(token.utf16Start) - val b = line.horizontalAt(token.utf16End) - val left = minOf(a, b) - val right = maxOf(a, b) - val key = when (token.kind) { TokenKind.DIGIT -> - if (decimalIndex >= 0 && i > decimalIndex) { - "F${fractionalPosition++}" - } else { - "I${integerPosition++}" - } - TokenKind.GROUP_SEPARATOR -> "G${integerDigitsToRight[i]}" - TokenKind.DECIMAL_SEPARATOR -> "DEC" + if (token.fractional) "F${fractionalPosition++}" + else "I${integerPosition++}" + TokenKind.GROUP_SEPARATOR -> structuralKey("G${integerDigitsToRight[i]}", token.text) + TokenKind.DECIMAL_SEPARATOR -> structuralKey("DEC", token.text) TokenKind.SIGN -> "S" - TokenKind.OTHER -> "O$i" + TokenKind.OTHER -> visualAffixKey(i, prefixRank, suffixRank) + } + val physicsKind = when (token.kind) { + TokenKind.SIGN, TokenKind.OTHER -> TokenKind.DIGIT + else -> token.kind } result.add( KeyedSlot( key = key, - kind = token.kind, + kind = physicsKind, + semanticKind = token.kind, char = token.text, - centerFromLeft = (left + right) / 2f, + centerFromLeft = centers[i], totalWidth = line.totalWidth, - leftFromLeft = left, - rightFromLeft = right, + leftFromLeft = lefts[i], + rightFromLeft = rights[i], utf16Start = token.utf16Start, utf16End = token.utf16End, ) ) } - return result + return result.sortedWith( + compareBy { it.centerFromLeft } + .thenBy { it.leftFromLeft } + .thenBy { it.utf16Start } + ) + } + + private fun structuralKey(base: String, glyph: String): String = "$base:$glyph" + + private fun visualAffixKey( + tokenIndex: Int, + prefixRank: IntArray, + suffixRank: IntArray, + ): String = when { + prefixRank[tokenIndex] >= 0 -> "P${prefixRank[tokenIndex]}" + suffixRank[tokenIndex] >= 0 -> "X${suffixRank[tokenIndex]}" + else -> "O$tokenIndex" + } + + private fun tokenizeSemantically( + text: String, + spans: List, + ): List = rawTokens(text).map { raw -> + val span = spans.firstOrNull { raw.utf16Start >= it.start && raw.utf16End <= it.end } + val kind = when (span?.kind) { + NumericFieldKind.INTEGER, NumericFieldKind.FRACTION -> + if (Character.isDigit(raw.codePoint)) TokenKind.DIGIT else TokenKind.OTHER + NumericFieldKind.GROUP_SEPARATOR -> TokenKind.GROUP_SEPARATOR + NumericFieldKind.DECIMAL_SEPARATOR -> TokenKind.DECIMAL_SEPARATOR + NumericFieldKind.SIGN -> TokenKind.SIGN + null -> TokenKind.OTHER + } + Token( + text = raw.text, + kind = kind, + fractional = span?.kind == NumericFieldKind.FRACTION, + utf16Start = raw.utf16Start, + utf16End = raw.utf16End, + ) } - private fun tokenize( + /** Defensive path if ICU failed to provide fields. */ + private fun tokenizeFallback( text: String, groupSep: Char, decimalSep: Char, minusSign: Char, ): List { - val out = ArrayList() + val raw = rawTokens(text) + if (raw.isEmpty()) return emptyList() + + val firstDigit = raw.indexOfFirst { Character.isDigit(it.codePoint) } + val lastDigit = raw.indexOfLast { Character.isDigit(it.codePoint) } + val decimalIndex = when { + firstDigit < 0 -> -1 + else -> { + val between = + (firstDigit + 1 until lastDigit) + .lastOrNull { raw[it].codePoint == decimalSep.code } + between + ?: (lastDigit + 1) + .takeIf { it < raw.size && raw[it].codePoint == decimalSep.code } + ?: -1 + } + } + val integerEnd = if (decimalIndex >= 0) decimalIndex else lastDigit + 1 + + return raw.mapIndexed { index, token -> + val digit = Character.isDigit(token.codePoint) + val fractional = digit && decimalIndex >= 0 && index > decimalIndex + val kind = when { + digit -> TokenKind.DIGIT + index == decimalIndex -> TokenKind.DECIMAL_SEPARATOR + firstDigit >= 0 && + index > firstDigit && + index < integerEnd && + token.codePoint == groupSep.code && + hasDigitBefore(raw, index, firstDigit) && + hasDigitAfter(raw, index, integerEnd) -> TokenKind.GROUP_SEPARATOR + token.codePoint == minusSign.code && isSignPosition(raw, index, firstDigit, lastDigit) -> + TokenKind.SIGN + else -> TokenKind.OTHER + } + + Token( + text = token.text, + kind = kind, + fractional = fractional, + utf16Start = token.utf16Start, + utf16End = token.utf16End, + ) + } + } + + private fun rawTokens(text: String): List { + val out = ArrayList() var utf16Offset = 0 val iterator = text.codePoints().iterator() - while (iterator.hasNext()) { val cp = iterator.next() val char = String(Character.toChars(cp)) val start = utf16Offset utf16Offset += char.length + out.add(RawToken(char, cp, start, utf16Offset)) + } + return out + } - val kind = when { - cp == groupSep.code -> TokenKind.GROUP_SEPARATOR - cp == decimalSep.code -> TokenKind.DECIMAL_SEPARATOR - cp == minusSign.code -> TokenKind.SIGN - Character.isDigit(cp) -> TokenKind.DIGIT - else -> TokenKind.OTHER - } + private fun isDirectionalToken(token: Token): Boolean = + isDirectionalMark(token.text.codePointAt(0)) + + private fun hasDigitBefore(raw: List, index: Int, lowerBound: Int): Boolean { + for (i in index - 1 downTo lowerBound) { + if (Character.isDigit(raw[i].codePoint)) return true + if (!isDirectionalMark(raw[i].codePoint)) return false + } + return false + } - out.add(Token(char, kind, start, utf16Offset)) + private fun hasDigitAfter(raw: List, index: Int, upperBound: Int): Boolean { + for (i in index + 1 until upperBound) { + if (Character.isDigit(raw[i].codePoint)) return true + if (!isDirectionalMark(raw[i].codePoint)) return false } + return false + } - return out + private fun isSignPosition( + raw: List, + index: Int, + firstDigit: Int, + lastDigit: Int, + ): Boolean { + if (firstDigit < 0 || (index in firstDigit..lastDigit)) return false + val before = raw.getOrNull(index - 1)?.codePoint + val after = raw.getOrNull(index + 1)?.codePoint + return !(before != null && after != null && Character.isLetter(before) && Character.isLetter(after)) } -} + + private fun isDirectionalMark(codePoint: Int): Boolean = + codePoint == 0x061C || codePoint == 0x200E || codePoint == 0x200F +} \ No newline at end of file diff --git a/android/src/test/java/com/numerictext/FormatGeometryPinTest.kt b/android/src/test/java/com/numerictext/FormatGeometryPinTest.kt new file mode 100644 index 0000000..366272e --- /dev/null +++ b/android/src/test/java/com/numerictext/FormatGeometryPinTest.kt @@ -0,0 +1,275 @@ +package com.numerictext + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FormatGeometryPinTest { + + private fun slot( + key: String, + char: String, + semanticKind: TokenKind, + center: Float, + ): KeyedSlot = + KeyedSlot( + key = key, + kind = if (semanticKind == TokenKind.SIGN || semanticKind == TokenKind.OTHER) { + TokenKind.DIGIT + } else { + semanticKind + }, + semanticKind = semanticKind, + char = char, + centerFromLeft = center, + totalWidth = 100f, + leftFromLeft = center - 5f, + rightFromLeft = center + 5f, + utf16Start = 0, + utf16End = char.length, + ) + + @Test + fun valueUpdate_releasesCurrentFormatPinAndRestoresSuffixReflow() { + val oldFormat = + listOf( + slot("P0", "$", TokenKind.OTHER, center = 20f), + slot("I0", "1", TokenKind.DIGIT, center = 70f), + ) + val eurSmall = + listOf( + slot("I0", "1", TokenKind.DIGIT, center = 30f), + slot("X0", "€", TokenKind.OTHER, center = 80f), + ) + val eurLarge = + listOf( + slot("I0", "1", TokenKind.DIGIT, center = 20f), + slot("I1", "0", TokenKind.DIGIT, center = 40f), + slot("X0", "€", TokenKind.OTHER, center = 90f), + ) + + val engine = NumericRollEngine() + engine.reset( + layout = oldFormat, + text = "$1", + lineHeight = 100f, + rasterId = 1, + blurLengthPx = 50f, + ) + engine.setTarget( + layout = eurSmall, + text = "1€", + direction = 1, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = 2, + blurLengthPx = 50f, + ) + + Thread.sleep(220L) + engine.step(0.01f) + + val pinnedEuro = engine.samples().first { it.ch == "€" } + assertEquals(30f, pinnedEuro.x, 0.01f) + + // A value-only update in the same EUR format must release the temporary format pin. The suffix + // starts from the exact X already on screen, then resumes the original horizontal reflow as the + // numeric text grows. + engine.setTarget( + layout = eurLarge, + text = "10€", + direction = 1, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = 3, + blurLengthPx = 50f, + ) + + val beforeStep = engine.samples().first { it.ch == "€" } + assertEquals(30f, beforeStep.x, 0.01f) + + engine.step(0.016f) + + val movingEuro = engine.samples().first { it.ch == "€" } + assertTrue(movingEuro.x > 30f) + assertTrue(movingEuro.x < 40f) + } + + @Test + fun repeatedSettledFormatChange_startsFromCanonicalTargetState() { + val negative = + listOf( + slot("S", "-", TokenKind.SIGN, center = 10f), + slot("P0", "U", TokenKind.OTHER, center = 30f), + slot("I0", "9", TokenKind.DIGIT, center = 75f), + ) + val positive = + listOf( + slot("P0", "U", TokenKind.OTHER, center = 20f), + slot("I0", "0", TokenKind.DIGIT, center = 80f), + ) + + val engine = NumericRollEngine() + engine.reset( + layout = negative, + text = "-U9", + lineHeight = 100f, + rasterId = 1, + blurLengthPx = 50f, + ) + + fun target(layout: List, text: String, direction: Int, rasterId: Int) { + engine.setTarget( + layout = layout, + text = text, + direction = direction, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = rasterId, + blurLengthPx = 50f, + ) + } + + fun finishUntilVisuallySettledButStillRunning(): Boolean { + Thread.sleep(240L) + repeat(120) { + engine.step(0.016f) + val samples = engine.samples() + if ( + engine.isRunning && + samples.isNotEmpty() && + samples.all { it.stable && it.alpha >= 0.99f } + ) { + return true + } + } + return false + } + + target(positive, "U0", direction = 1, rasterId = 2) + assertTrue(finishUntilVisuallySettledButStillRunning()) + + target(negative, "-U9", direction = -1, rasterId = 3) + assertTrue(finishUntilVisuallySettledButStillRunning()) + + // This is the second A -> B transition from the USD recording. The previous frame is already + // visually settled, so hidden historical entries must be collapsed before scheduling the new + // per-glyph wave. U therefore receives a fresh old/new roll instead of remaining as a static + // full-opacity anchor while only the digit changes. + target(positive, "U0", direction = 1, rasterId = 4) + Thread.sleep(130L) + engine.step(0.016f) + + val uSamples = engine.samples().filter { it.key == "P0" && it.ch == "U" } + assertTrue(uSamples.size >= 2) + assertTrue(uSamples.any { it.blurLengthPx > 0f || it.offsetY != 0f }) + } + + @Test + fun rapidFormatTaps_dropTargetsThatNeverReachedTheScreen() { + val negative = + listOf( + slot("S", "-", TokenKind.SIGN, center = 10f), + slot("P0", "U", TokenKind.OTHER, center = 30f), + slot("I0", "9", TokenKind.DIGIT, center = 75f), + ) + val positive = + listOf( + slot("P0", "U", TokenKind.OTHER, center = 20f), + slot("I0", "0", TokenKind.DIGIT, center = 80f), + ) + + val engine = NumericRollEngine() + engine.reset( + layout = negative, + text = "-U9", + lineHeight = 100f, + rasterId = 1, + blurLengthPx = 50f, + ) + + fun target(layout: List, text: String, direction: Int, rasterId: Int) { + engine.setTarget( + layout = layout, + text = text, + direction = direction, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = rasterId, + blurLengthPx = 50f, + ) + } + + // No frame is advanced between these requests, so the intermediate positive targets have never + // been visible. The final request is the same layout that is physically still on screen. + target(positive, "U0", direction = 1, rasterId = 2) + target(negative, "-U9", direction = -1, rasterId = 3) + target(positive, "U0", direction = 1, rasterId = 4) + target(negative, "-U9", direction = -1, rasterId = 5) + + Thread.sleep(260L) + engine.step(0.016f) + + val samples = engine.samples() + assertEquals(3, samples.size) + assertEquals("-", samples.single { it.key == "S" }.ch) + assertEquals("U", samples.single { it.key == "P0" }.ch) + assertEquals("9", samples.single { it.key == "I0" }.ch) + assertTrue(samples.all { it.stable }) + } + + @Test + fun reversingAfterSignRemovalStarted_revivesTheOutgoingGlyph() { + val negative = + listOf( + slot("S", "-", TokenKind.SIGN, center = 10f), + slot("P0", "U", TokenKind.OTHER, center = 30f), + slot("I0", "9", TokenKind.DIGIT, center = 75f), + ) + val positive = + listOf( + slot("P0", "U", TokenKind.OTHER, center = 20f), + slot("I0", "0", TokenKind.DIGIT, center = 80f), + ) + + val engine = NumericRollEngine() + engine.reset( + layout = negative, + text = "-U9", + lineHeight = 100f, + rasterId = 1, + blurLengthPx = 50f, + ) + + engine.setTarget( + layout = positive, + text = "U0", + direction = 1, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = 2, + blurLengthPx = 50f, + ) + + // The sign is the first visual event in this fixture; after the format onset it has committed to + // EXIT while the rest of the wave is still in flight. + Thread.sleep(130L) + engine.step(0.016f) + assertEquals(NumericRollEngine.GlyphRole.EXIT, engine.samples().first { it.key == "S" }.role) + + engine.setTarget( + layout = negative, + text = "-U9", + direction = -1, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = 3, + blurLengthPx = 50f, + ) + engine.step(0.016f) + + val sign = engine.samples().first { it.key == "S" && it.ch == "-" } + assertEquals(NumericRollEngine.GlyphRole.ENTER, sign.role) + assertTrue(sign.blurLengthPx > 0f || sign.offsetY != 0f) + } +} diff --git a/android/src/test/java/com/numerictext/SemanticTokenizationTest.kt b/android/src/test/java/com/numerictext/SemanticTokenizationTest.kt new file mode 100644 index 0000000..80a8027 --- /dev/null +++ b/android/src/test/java/com/numerictext/SemanticTokenizationTest.kt @@ -0,0 +1,95 @@ +package com.numerictext + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SemanticTokenizationTest { + private fun line(text: String): TextLineGeometry = + TextLineGeometry( + text = text, + totalWidth = text.length.toFloat(), + horizontals = FloatArray(text.length + 1) { it.toFloat() }, + layout = null, + horizontalOrigin = 0f, + ) + + private fun span(text: String, needle: String, kind: NumericFieldKind, occurrence: Int = 0): NumericSemanticSpan { + var from = 0 + var start = -1 + repeat(occurrence + 1) { + start = text.indexOf(needle, from) + require(start >= 0) { "Missing '$needle' in '$text'" } + from = start + needle.length + } + return NumericSemanticSpan(start, start + needle.length, kind) + } + + private fun keyed( + text: String, + group: Char, + decimal: Char, + spans: List, + ): List = TransitionLogic.layoutKeyedSlots( + formatted = text, + groupSep = group, + decimalSep = decimal, + minusSign = '-', + line = line(text), + semanticSpans = spans, + ) + + private fun assertUniqueKeys(slots: List) { + assertEquals(slots.size, slots.map { it.key }.toSet().size) + } + + @Test + fun panamaCurrencyPrefixDot_remainsAffixWhileNumericDotIsDecimal() { + val text = "B/. 1,234.50" + val spans = listOf( + span(text, "1", NumericFieldKind.INTEGER), + span(text, ",", NumericFieldKind.GROUP_SEPARATOR), + span(text, "234", NumericFieldKind.INTEGER), + span(text, ".", NumericFieldKind.DECIMAL_SEPARATOR, occurrence = 1), + span(text, "50", NumericFieldKind.FRACTION), + ) + val slots = keyed(text, ',', '.', spans) + + assertUniqueKeys(slots) + assertEquals(1, slots.count { it.kind == TokenKind.DECIMAL_SEPARATOR }) + assertEquals(1, slots.count { it.char == "." && it.kind == TokenKind.OTHER }) + assertEquals(",", slots.single { it.key == "G3:," }.char) + } + + @Test + fun arabicCurrencySuffixDots_neverBecomeNumericDecimals() { + val text = "1,234.50 د.إ." + val spans = listOf( + span(text, "1", NumericFieldKind.INTEGER), + span(text, ",", NumericFieldKind.GROUP_SEPARATOR), + span(text, "234", NumericFieldKind.INTEGER), + span(text, ".", NumericFieldKind.DECIMAL_SEPARATOR), + span(text, "50", NumericFieldKind.FRACTION), + ) + val slots = keyed(text, ',', '.', spans) + + assertUniqueKeys(slots) + assertEquals(1, slots.count { it.kind == TokenKind.DECIMAL_SEPARATOR }) + assertEquals(2, slots.count { it.char == "." && it.kind == TokenKind.OTHER }) + } + + @Test + fun currencyNameHyphen_remainsAffixWhileLeadingMinusIsSign() { + val text = "-1,00 US-Dollar" + val spans = listOf( + span(text, "-", NumericFieldKind.SIGN), + span(text, "1", NumericFieldKind.INTEGER), + span(text, ",", NumericFieldKind.DECIMAL_SEPARATOR), + span(text, "00", NumericFieldKind.FRACTION), + ) + val slots = keyed(text, '.', ',', spans) + + assertUniqueKeys(slots) + assertEquals(1, slots.count { it.kind == TokenKind.SIGN }) + assertEquals(1, slots.count { it.char == "-" && it.kind == TokenKind.OTHER }) + } +} diff --git a/android/src/test/java/com/numerictext/TransitionLogicTest.kt b/android/src/test/java/com/numerictext/TransitionLogicTest.kt index 57f906a..1365499 100644 --- a/android/src/test/java/com/numerictext/TransitionLogicTest.kt +++ b/android/src/test/java/com/numerictext/TransitionLogicTest.kt @@ -1,5 +1,6 @@ package com.numerictext +import kotlin.math.abs import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -21,10 +22,14 @@ class TransitionLogicTest { private fun keyMap(text: String): Map = keyed(text).associate { it.key to it.char } + private fun assertUniqueKeys(slots: List) { + assertEquals(slots.size, slots.map { it.key }.toSet().size) + } + @Test fun integerDigits_areAnchoredFromTheLeft() { assertEquals( - mapOf("I0" to "2", "G3" to ",", "I1" to "5", "I2" to "7", "I3" to "6"), + mapOf("I0" to "2", "G3:," to ",", "I1" to "5", "I2" to "7", "I3" to "6"), keyMap("2,576"), ) } @@ -42,23 +47,23 @@ class TransitionLogicTest { assertEquals("0", next["I1"]) assertEquals("0", next["I2"]) assertEquals("0", next["I3"]) - assertEquals(",", next["G3"]) + assertEquals(",", next["G3:,"]) } @Test fun groupSeparator_isStructuralAndBornOnCarry() { - assertFalse(keyMap("999").containsKey("G3")) - assertEquals(",", keyMap("1,000")["G3"]) + assertFalse(keyMap("999").containsKey("G3:,")) + assertEquals(",", keyMap("1,000")["G3:,"]) } @Test fun fractions_areAnchoredFromTheDecimalPoint() { assertEquals( - mapOf("I0" to "1", "DEC" to ".", "F0" to "9"), + mapOf("I0" to "1", "DEC:." to ".", "F0" to "9"), keyMap("1.9"), ) assertEquals( - mapOf("I0" to "2", "DEC" to ".", "F0" to "0"), + mapOf("I0" to "2", "DEC:." to ".", "F0" to "0"), keyMap("2.0"), ) } @@ -69,10 +74,134 @@ class TransitionLogicTest { assertEquals("1", keyMap("-1")["I0"]) } + @Test + fun currencySymbol_keepsItsKeyWhenTheNumberGrowsADigit() { + assertEquals("$", keyMap("\$999")["P0"]) + assertEquals("$", keyMap("\$1,000")["P0"]) + } + + @Test + fun currencySymbol_keepsItsKeyAcrossASignChange() { + assertEquals("$", keyMap("\$1.00")["P0"]) + assertEquals("$", keyMap("-\$1.00")["P0"]) + assertEquals("-", keyMap("-\$1.00")["S"]) + } + + @Test + fun differentStructuralGlyphs_shareTheSameSemanticPosition() { + assertEquals("$", keyMap("\$1")["P0"]) + assertEquals("€", keyMap("€1")["P0"]) + } + + @Test + fun accountingBrackets_sitOutsideTheSymbol() { + val accounting = keyMap("(\$1.00)") + assertEquals("$", accounting["P0"]) + assertEquals("(", accounting["P1"]) + assertEquals(")", accounting["X0"]) + } + + @Test + fun trailingSymbol_isKeyedFromTheEndOfTheNumber() { + val small = TransitionLogic.layoutKeyedSlots("999,00\u00A0€", '.', ',', '-', line("999,00\u00A0€")) + .associate { it.key to it.char } + val large = + TransitionLogic.layoutKeyedSlots("1.000,00\u00A0€", '.', ',', '-', line("1.000,00\u00A0€")) + .associate { it.key to it.char } + + assertEquals("€", small["X1"]) + assertEquals("€", large["X1"]) + assertEquals("\u00A0", small["X0"]) + assertEquals("\u00A0", large["X0"]) + } + + @Test + fun percentSign_isKeyedFromTheEndOfTheNumber() { + assertEquals("%", keyMap("9%")["X0"]) + assertEquals("%", keyMap("99%")["X0"]) + } + + @Test + fun currencyName_keysEachLetterOutwardFromTheNumber() { + val letters = keyMap("1.00 US dollars") + assertEquals(" ", letters["X0"]) + assertEquals("U", letters["X1"]) + assertEquals("s", letters["X10"]) + } + + @Test + fun punctuationInsideCurrencyPrefix_isNotNumericStructure() { + val text = "B/. 1,234.50" + val slots = keyed(text) + val map = slots.associate { it.key to it.char } + + assertUniqueKeys(slots) + assertEquals(".", map["P1"]) + assertEquals("/", map["P2"]) + assertEquals("B", map["P3"]) + assertEquals(",", map["G3:,"]) + assertEquals(".", map["DEC:."]) + assertEquals("1", map["I0"]) + assertEquals("5", map["F0"]) + assertEquals(TokenKind.OTHER, slots.single { it.key == "P1" }.semanticKind) + assertEquals(TokenKind.GROUP_SEPARATOR, slots.single { it.key == "G3:," }.semanticKind) + assertEquals(TokenKind.DECIMAL_SEPARATOR, slots.single { it.key == "DEC:." }.semanticKind) + } + + @Test + fun punctuationInsideCurrencySuffix_isNotNumericStructure() { + val text = "1,234.50 د.إ." + val slots = keyed(text) + + assertUniqueKeys(slots) + assertEquals(1, slots.count { it.semanticKind == TokenKind.DECIMAL_SEPARATOR }) + assertEquals(2, slots.count { it.char == "." && it.semanticKind == TokenKind.OTHER }) + } + + @Test + fun hyphenInsideCurrencyName_isNotTheNumericSign() { + val text = "-1,00 US-Dollar" + val slots = TransitionLogic.layoutKeyedSlots(text, '.', ',', '-', line(text)) + + assertUniqueKeys(slots) + assertEquals(1, slots.count { it.semanticKind == TokenKind.SIGN }) + assertEquals("-", slots.single { it.semanticKind == TokenKind.SIGN }.char) + assertEquals(1, slots.count { it.char == "-" && it.semanticKind == TokenKind.OTHER }) + } + + @Test + fun affixesAndSigns_useValidatedDigitPhysics() { + val slots = keyed("-\$1,234.50%") + + val sign = slots.single { it.key == "S" } + val prefix = slots.single { it.key == "P0" } + val suffix = slots.single { it.key == "X0" } + assertEquals(TokenKind.DIGIT, sign.kind) + assertEquals(TokenKind.DIGIT, prefix.kind) + assertEquals(TokenKind.DIGIT, suffix.kind) + assertEquals(TokenKind.SIGN, sign.semanticKind) + assertEquals(TokenKind.OTHER, prefix.semanticKind) + assertEquals(TokenKind.OTHER, suffix.semanticKind) + + val group = slots.single { it.key == "G3:," } + val decimal = slots.single { it.key == "DEC:." } + assertEquals(TokenKind.GROUP_SEPARATOR, group.kind) + assertEquals(TokenKind.DECIMAL_SEPARATOR, decimal.kind) + } + + @Test + fun bidiDirectionalMarks_doNotCreateTransitionSlots() { + val text = "\u061C-1" + val slots = keyed(text) + + assertEquals(listOf("S", "I0"), slots.map { it.key }) + assertEquals(listOf("-", "1"), slots.map { it.char }) + } + @Test fun tokenBoundsComeFromTheFullLineGeometry() { val slots = keyed("1,000") - val comma = slots.first { it.key == "G3" } + val comma = slots.first { it.key == "G3:," } assertEquals(1f, comma.leftFromLeft, 0.001f) assertEquals(2f, comma.rightFromLeft, 0.001f) assertEquals(5f, comma.totalWidth, 0.001f) @@ -124,6 +253,29 @@ class NumericRollEngineTest { repeat(frames) { engine.step(0.01f) } } + private fun slot( + key: String, + char: String, + semanticKind: TokenKind, + center: Float, + ): KeyedSlot = + KeyedSlot( + key = key, + kind = if (semanticKind == TokenKind.SIGN || semanticKind == TokenKind.OTHER) { + TokenKind.DIGIT + } else { + semanticKind + }, + semanticKind = semanticKind, + char = char, + centerFromLeft = center, + totalWidth = 100f, + leftFromLeft = center - 5f, + rightFromLeft = center + 5f, + utf16Start = 0, + utf16End = char.length, + ) + @Test fun increment_entersFromAboveAndExitsBelow() { val engine = NumericRollEngine() @@ -185,6 +337,76 @@ class NumericRollEngineTest { assertTrue("7" in chars) } + @Test + fun changedStructuralGlyph_rollsThroughTheSameColumn() { + val oldSlots = slots("\$1") + val newSlots = slots("€1") + assertEquals( + oldSlots.single { it.char == "$" }.key, + newSlots.single { it.char == "€" }.key, + ) + + val engine = NumericRollEngine() + reset(engine, "\$1") + target(engine, "€1", direction = 1, rasterId = 2) + advance(engine) + + val samples = engine.samples().filter { it.key == "P0" } + assertTrue(samples.any { it.ch == "€" }) + assertTrue(samples.any { it.ch == "$" }) + assertTrue(samples.first { it.ch == "$" }.blurLengthPx > 0f) + assertTrue(samples.first { it.ch == "€" }.offsetY < 0f) + assertTrue(samples.first { it.ch == "$" }.offsetY > 0f) + } + + @Test + fun formatChange_keepsOldAndNewGlyphsAtTheirOwnXWhileRolling() { + val oldLayout = + listOf( + slot("I0", "1", TokenKind.DIGIT, center = 30f), + slot("X0", "€", TokenKind.OTHER, center = 80f), + ) + val newLayout = + listOf( + slot("P0", "د", TokenKind.OTHER, center = 20f), + slot("I0", "1", TokenKind.DIGIT, center = 70f), + ) + + val engine = NumericRollEngine() + engine.reset( + layout = oldLayout, + text = "1€", + lineHeight = 100f, + rasterId = 1, + blurLengthPx = 50f, + ) + engine.setTarget( + layout = newLayout, + text = "د1", + direction = 1, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = 2, + blurLengthPx = 50f, + ) + + // The three visual roll events span WAVE_TOTAL_SECONDS. Let all of them become due, then + // integrate a frame so old/new copies are simultaneously observable. + Thread.sleep(220L) + advance(engine) + + val digitSamples = engine.samples().filter { it.key == "I0" && it.ch == "1" } + assertEquals(2, digitSamples.size) + assertTrue(digitSamples.any { abs(it.x - (-20f)) < 0.01f }) + assertTrue(digitSamples.any { abs(it.x - 20f) < 0.01f }) + assertTrue(digitSamples.all { abs(it.offsetY) > 0f }) + + val euro = engine.samples().first { it.ch == "€" } + val dirham = engine.samples().first { it.ch == "د" } + assertEquals(30f, euro.x, 0.01f) + assertEquals(-30f, dirham.x, 0.01f) + } + @Test fun snapToTargetLeavesOneStableTargetGlyph() { val engine = NumericRollEngine() diff --git a/android/src/test/java/com/numerictext/UsdCodeRepeatTransitionTest.kt b/android/src/test/java/com/numerictext/UsdCodeRepeatTransitionTest.kt new file mode 100644 index 0000000..06d72a6 --- /dev/null +++ b/android/src/test/java/com/numerictext/UsdCodeRepeatTransitionTest.kt @@ -0,0 +1,196 @@ +package com.numerictext + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class UsdCodeRepeatTransitionTest { + + private fun slot( + key: String, + char: String, + semanticKind: TokenKind, + center: Float, + ): KeyedSlot = + KeyedSlot( + key = key, + kind = + if (semanticKind == TokenKind.SIGN || semanticKind == TokenKind.OTHER) { + TokenKind.DIGIT + } else { + semanticKind + }, + semanticKind = semanticKind, + char = char, + centerFromLeft = center, + totalWidth = 140f, + leftFromLeft = center - 5f, + rightFromLeft = center + 5f, + utf16Start = 0, + utf16End = char.length, + ) + + @Test + fun secondPositiveToNegative_rollsUsdLettersWhileOldSignGhostIsStillExiting() { + // Put the sign at the end of this synthetic visual layout so U/S/D can be fully settled while + // the sign's outgoing entry is still above the render threshold. This deterministically + // reproduces the real USD-code failure: a fading sign ghost must be reversible, but must not + // make the active positive topology look as though it still contains a sign. + val negative = + listOf( + slot("P2", "U", TokenKind.OTHER, 10f), + slot("P1", "S", TokenKind.OTHER, 30f), + slot("P0", "D", TokenKind.OTHER, 50f), + slot("I0", "1", TokenKind.DIGIT, 80f), + slot("S", "-", TokenKind.SIGN, 110f), + ) + val positive = negative.filterNot { it.key == "S" } + + val engine = NumericRollEngine() + engine.reset( + layout = negative, + text = "USD 1-", + lineHeight = 100f, + rasterId = 1, + blurLengthPx = 50f, + ) + + engine.setTarget( + layout = positive, + text = "USD 1", + direction = 1, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = 2, + blurLengthPx = 50f, + ) + + repeat(25) { + Thread.sleep(20L) + engine.step(0.020f) + } + + val beforeReturn = engine.samples() + for (key in listOf("P2", "P1", "P0")) { + val letter = beforeReturn.single { it.key == key } + assertTrue("$key should be settled before the second transition", letter.stable) + } + assertEquals( + NumericRollEngine.GlyphRole.EXIT, + beforeReturn.first { it.key == "S" }.role, + ) + + // This is a second USD sign transition. The old '-' is still a fading EXIT. It must be + // available for reversal without being counted as active topology; otherwise formatGeometrySplit + // becomes false and the unchanged U/S/D glyphs incorrectly remain anchors. + engine.setTarget( + layout = negative, + text = "USD 1-", + direction = -1, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = 3, + blurLengthPx = 50f, + ) + + repeat(11) { + Thread.sleep(20L) + engine.step(0.020f) + } + + val duringReturn = engine.samples() + for (key in listOf("P2", "P1", "P0")) { + val letterSamples = duringReturn.filter { it.key == key } + assertTrue( + "$key must re-enter the unified format roll on every sign transition", + letterSamples.any { + !it.stable && (it.blurLengthPx > 0f || it.offsetY != 0f) + }, + ) + } + } + + @Test + fun negativeToPositive_afterVisibleLettersSettle_doesNotSelfReuseCurrentUsdGlyphs() { + val positive = + listOf( + slot("P2", "U", TokenKind.OTHER, 20f), + slot("P1", "S", TokenKind.OTHER, 40f), + slot("P0", "D", TokenKind.OTHER, 60f), + slot("I0", "1", TokenKind.DIGIT, 100f), + ) + val negative = + listOf( + slot("S", "-", TokenKind.SIGN, 10f), + slot("P2", "U", TokenKind.OTHER, 30f), + slot("P1", "S", TokenKind.OTHER, 50f), + slot("P0", "D", TokenKind.OTHER, 70f), + slot("I0", "9", TokenKind.DIGIT, 110f), + ) + + val engine = NumericRollEngine() + engine.reset( + layout = positive, + text = "USD 1", + lineHeight = 100f, + rasterId = 1, + blurLengthPx = 50f, + ) + + engine.setTarget( + layout = negative, + text = "-USD 9", + direction = -1, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = 2, + blurLengthPx = 50f, + ) + + // Reproduce the failing window in usd-2.webm: the visible USD letters have already converged, + // but hidden superseded entries keep the engine alive, so the following value change is a real + // reversal rather than a canonical fresh start. + var reachedWindow = false + for (step in 0 until 80) { + Thread.sleep(20L) + engine.step(0.020f) + val samples = engine.samples() + val usdSettled = + listOf("P2", "P1", "P0").all { key -> + val visible = samples.filter { it.key == key } + visible.size == 1 && visible.single().stable + } + if (engine.isRunning && usdSettled) { + reachedWindow = true + break + } + } + assertTrue("fixture must reach visually-settled USD while engine history is still active", reachedWindow) + + engine.setTarget( + layout = positive, + text = "USD 1", + direction = 1, + lineHeight = 100f, + animationDurationMs = 320L, + rasterId = 3, + blurLengthPx = 50f, + ) + + repeat(9) { + Thread.sleep(20L) + engine.step(0.020f) + } + + val duringPositiveReturn = engine.samples() + for (key in listOf("P2", "P1", "P0")) { + val letterSamples = duringPositiveReturn.filter { it.key == key } + assertTrue( + "$key must roll again instead of reusing the just-superseded current glyph", + letterSamples.any { + !it.stable && (it.blurLengthPx > 0f || it.offsetY != 0f) + }, + ) + } + } +} diff --git a/example/src/FormatLab.tsx b/example/src/FormatLab.tsx new file mode 100644 index 0000000..637a367 --- /dev/null +++ b/example/src/FormatLab.tsx @@ -0,0 +1,415 @@ +import { StatusBar } from 'expo-status-bar'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { NumericText, type NumericTextFormat } from 'react-native-numeric-text'; + +type Preset = { + label: string; + locale: string; + format: NumericTextFormat; + values: readonly [number, number]; + note: string; +}; + +type GtStep = { + presetIndex: number; + side: 0 | 1; + hold: number; + label: string; +}; + +const PRESETS: readonly Preset[] = [ + { + label: 'USD symbol', + locale: 'en-US', + format: { style: 'currency', currency: 'USD' }, + values: [999.99, 1000], + note: 'prefix symbol + grouping carry', + }, + { + label: 'EUR suffix', + locale: 'de-DE', + format: { style: 'currency', currency: 'EUR' }, + values: [999.99, 1000], + note: 'suffix symbol + comma decimal', + }, + { + label: 'PAB punctuation', + locale: 'es-PA', + format: { style: 'currency', currency: 'PAB' }, + values: [1234.5, 1235.5], + note: 'B/. contains the same dot as the numeric decimal mark', + }, + { + label: 'AED RTL', + locale: 'ar-AE', + format: { style: 'currency', currency: 'AED' }, + values: [1234.5, 1235.5], + note: 'RTL + currency punctuation + bidi marks', + }, + { + label: 'Accounting', + locale: 'en-US', + format: { + style: 'currency', + currency: 'USD', + currencySign: 'accounting', + }, + values: [-999.99, 1000], + note: 'parentheses disappear while the number crosses zero', + }, + { + label: 'USD code', + locale: 'en-US', + format: { + style: 'currency', + currency: 'USD', + currencyDisplay: 'code', + }, + values: [-999.99, 1000], + note: 'stable ISO-code affix + sign change', + }, + { + label: 'Percent', + locale: 'en-US', + format: { style: 'percent' }, + values: [9.99, 10], + note: 'x100 display + grouping carry around 1,000%', + }, + { + label: 'JPY zero digits', + locale: 'ja-JP', + format: { style: 'currency', currency: 'JPY' }, + values: [999, 1000], + note: 'currency default has no fraction digits', + }, + { + label: 'BHD three digits', + locale: 'en-US', + format: { style: 'currency', currency: 'BHD' }, + values: [999.99, 1000], + note: 'three default fraction digits + grouping carry', + }, +]; + +const BURST_INTERVAL_MS = 85; + +// One tap produces the same event stream on iOS and Android. Long holds expose settled states; +// 85 ms runs exercise retriggering while the previous numeric transition is still in flight. +const GT_SEQUENCE: readonly GtStep[] = [ + { presetIndex: 0, side: 0, hold: 650, label: 'USD symbol A' }, + { presetIndex: 0, side: 1, hold: 850, label: 'USD symbol B' }, + // Same numeric value (1000), same locale and currency: only currencyDisplay changes. These two + // steps catch platforms that scope numericText animation to the Double instead of the rendered + // string, and Android format transactions that accidentally insert an intermediate baseline. + { + presetIndex: 5, + side: 1, + hold: 850, + label: 'USD format-only symbol → code', + }, + { + presetIndex: 0, + side: 1, + hold: 850, + label: 'USD format-only code → symbol', + }, + + { presetIndex: 1, side: 0, hold: 650, label: 'EUR A' }, + { presetIndex: 1, side: 1, hold: 850, label: 'EUR B' }, + { presetIndex: 1, side: 0, hold: 850, label: 'EUR A return' }, + + { presetIndex: 2, side: 0, hold: 650, label: 'PAB A' }, + { presetIndex: 2, side: 1, hold: 850, label: 'PAB B' }, + { presetIndex: 2, side: 0, hold: BURST_INTERVAL_MS, label: 'PAB burst 1' }, + { presetIndex: 2, side: 1, hold: BURST_INTERVAL_MS, label: 'PAB burst 2' }, + { presetIndex: 2, side: 0, hold: BURST_INTERVAL_MS, label: 'PAB burst 3' }, + { presetIndex: 2, side: 1, hold: 950, label: 'PAB burst settle' }, + + { presetIndex: 3, side: 0, hold: 650, label: 'AED A' }, + { presetIndex: 3, side: 1, hold: 850, label: 'AED B' }, + { presetIndex: 3, side: 0, hold: BURST_INTERVAL_MS, label: 'AED burst 1' }, + { presetIndex: 3, side: 1, hold: BURST_INTERVAL_MS, label: 'AED burst 2' }, + { presetIndex: 3, side: 0, hold: BURST_INTERVAL_MS, label: 'AED burst 3' }, + { presetIndex: 3, side: 1, hold: 950, label: 'AED burst settle' }, + + { presetIndex: 4, side: 0, hold: 650, label: 'Accounting negative' }, + { presetIndex: 4, side: 1, hold: 950, label: 'Accounting positive' }, + { presetIndex: 5, side: 0, hold: 650, label: 'USD code negative' }, + { presetIndex: 5, side: 1, hold: 950, label: 'USD code positive' }, + { presetIndex: 6, side: 0, hold: 650, label: 'Percent A' }, + { presetIndex: 6, side: 1, hold: 950, label: 'Percent B' }, + { presetIndex: 7, side: 0, hold: 650, label: 'JPY A' }, + { presetIndex: 7, side: 1, hold: 950, label: 'JPY B' }, + { presetIndex: 8, side: 0, hold: 650, label: 'BHD A' }, + { presetIndex: 8, side: 1, hold: 1100, label: 'BHD B' }, +]; + +export function FormatLab() { + const [state, setState] = useState(() => ({ + presetIndex: 0, + side: 0 as 0 | 1, + })); + const [gtStepIndex, setGtStepIndex] = useState(null); + const timers = useRef[]>([]); + const preset = PRESETS[state.presetIndex]!; + const value = preset.values[state.side]; + const gtStep = gtStepIndex == null ? null : GT_SEQUENCE[gtStepIndex]; + + const clearTimers = useCallback(() => { + for (const timer of timers.current) clearTimeout(timer); + timers.current = []; + }, []); + + const cancelGtRun = useCallback(() => { + clearTimers(); + setGtStepIndex(null); + }, [clearTimers]); + + useEffect( + () => () => { + for (const timer of timers.current) clearTimeout(timer); + }, + [] + ); + + const selectPreset = useCallback( + (presetIndex: number) => { + cancelGtRun(); + // Change format and value in one React update so Android receives one logical transaction. + setState({ presetIndex, side: 1 }); + }, + [cancelGtRun] + ); + + const setSide = useCallback( + (side: 0 | 1) => { + cancelGtRun(); + setState((current) => ({ ...current, side })); + }, + [cancelGtRun] + ); + + const burst = useCallback(() => { + cancelGtRun(); + const sequence: readonly (0 | 1)[] = [1, 0, 1, 0, 1]; + sequence.forEach((side, index) => { + timers.current.push( + setTimeout(() => { + setState((current) => ({ ...current, side })); + }, index * BURST_INTERVAL_MS) + ); + }); + }, [cancelGtRun]); + + const nextPresetAndValue = useCallback(() => { + cancelGtRun(); + setState((current) => ({ + presetIndex: (current.presetIndex + 1) % PRESETS.length, + side: current.side === 0 ? 1 : 0, + })); + }, [cancelGtRun]); + + const runGtSequence = useCallback(() => { + clearTimers(); + + const first = GT_SEQUENCE[0]!; + setGtStepIndex(0); + setState({ presetIndex: first.presetIndex, side: first.side }); + + let elapsed = 0; + for (let index = 1; index < GT_SEQUENCE.length; index += 1) { + elapsed += GT_SEQUENCE[index - 1]!.hold; + const step = GT_SEQUENCE[index]!; + timers.current.push( + setTimeout(() => { + setGtStepIndex(index); + setState({ presetIndex: step.presetIndex, side: step.side }); + }, elapsed) + ); + } + + elapsed += GT_SEQUENCE[GT_SEQUENCE.length - 1]!.hold; + timers.current.push(setTimeout(() => setGtStepIndex(null), elapsed)); + }, [clearTimers]); + + const gtMarker = + gtStep == null + ? `manual · ${Platform.OS}` + : [ + `GT ${String(gtStepIndex! + 1).padStart(2, '0')}/${GT_SEQUENCE.length}`, + Platform.OS, + gtStep.label, + ].join(' · '); + + return ( + + + + + {preset.label} + + {preset.locale} · {preset.note} + + {gtMarker} + + raw: {String(value)} + + + + setSide(0)} /> + setSide(1)} /> + + + + + + + {PRESETS.map((item, index) => ( + selectPreset(index)} + style={({ pressed }) => [ + styles.preset, + index === state.presetIndex && styles.presetSelected, + pressed && styles.pressed, + ]} + > + + {item.label} + + + ))} + + + ); +} + +function Action({ label, onPress }: { label: string; onPress: () => void }) { + return ( + [styles.action, pressed && styles.pressed]} + > + {label} + + ); +} + +const INK = '#171719'; + +const styles = StyleSheet.create({ + screen: { + flex: 1, + justifyContent: 'center', + paddingHorizontal: 20, + paddingVertical: 28, + gap: 28, + backgroundColor: '#fbfbf9', + }, + readout: { + minHeight: 280, + alignItems: 'center', + justifyContent: 'center', + gap: 9, + }, + title: { + color: INK, + fontSize: 20, + fontWeight: '700', + }, + meta: { + maxWidth: 420, + color: '#6c6c72', + fontSize: 13, + textAlign: 'center', + }, + gtMarker: { + color: '#8b8b91', + fontSize: 11, + fontVariant: ['tabular-nums'], + textAlign: 'center', + }, + number: { + color: INK, + fontSize: 38, + fontWeight: '700', + fontVariant: ['tabular-nums'], + }, + raw: { + color: '#8b8b91', + fontSize: 12, + }, + actions: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 10, + }, + action: { + minHeight: 42, + justifyContent: 'center', + paddingHorizontal: 14, + borderWidth: StyleSheet.hairlineWidth, + borderColor: '#b8b8bd', + borderRadius: 12, + backgroundColor: '#ffffff', + }, + actionText: { + color: INK, + fontSize: 13, + fontWeight: '700', + }, + presets: { + alignItems: 'center', + gap: 8, + paddingHorizontal: 2, + }, + preset: { + minHeight: 38, + justifyContent: 'center', + paddingHorizontal: 12, + borderRadius: 19, + backgroundColor: '#ececef', + }, + presetSelected: { + backgroundColor: INK, + }, + presetText: { + color: INK, + fontSize: 12, + fontWeight: '600', + }, + presetTextSelected: { + color: '#ffffff', + }, + pressed: { + opacity: 0.65, + }, +}); diff --git a/ios/NumericTextSwiftUIHost.swift b/ios/NumericTextSwiftUIHost.swift index 02b8803..922fea5 100644 --- a/ios/NumericTextSwiftUIHost.swift +++ b/ios/NumericTextSwiftUIHost.swift @@ -28,6 +28,13 @@ public final class NumericTextSwiftUIHost: UIView { /// number moved. `nil` until the first render, which must not animate. private var lastValue: Double? + /// The formatting props, exactly as `src/numberFormat.ts` resolved them, and the formatter they + /// build. Kept together so the formatter is rebuilt only when something about it changed: + /// `updateProps:` forwards every prop on every value change, and a `NumberFormatter` per frame + /// of a press-and-hold is real work for no result. + private var formatSpec = FormatSpec() + private lazy var formatter = Self.makeFormatter(formatSpec) + @objc public override init(frame: CGRect) { super.init(frame: frame) @@ -53,28 +60,59 @@ public final class NumericTextSwiftUIHost: UIView { fatalError("NumericTextSwiftUIHost is created in code only") } + /** + * The formatting props. Applied before the value, because the value is drawn through them. + * + * Separate from `apply` so neither selector grows past reading: between them they carry + * everything the component exposes, and Objective-C spells every argument out. + */ // swiftlint:disable:next function_parameter_count - @objc(applyValue:locale:direction:reduceMotion:useGrouping:minimumFractionDigits:maximumFractionDigits:fontSize:fontWeight:fontFamily:textColor:) - public func apply( - value: Double, + @objc(applyFormatWithLocale:numberStyle:currency:currencyDisplay:currencySign:useGrouping:minimumIntegerDigits:minimumFractionDigits:maximumFractionDigits:minimumSignificantDigits:maximumSignificantDigits:) + public func applyFormat( locale: String, - direction: String, - reduceMotion: String, + numberStyle: String, + currency: String, + currencyDisplay: String, + currencySign: String, useGrouping: Bool, + minimumIntegerDigits: Int, minimumFractionDigits: Int, maximumFractionDigits: Int, - fontSize: CGFloat, - fontWeight: String, - fontFamily: String?, - textColor: UIColor? + minimumSignificantDigits: Int, + maximumSignificantDigits: Int ) { - model.text = Self.format( - value, + let next = FormatSpec( locale: locale, + numberStyle: numberStyle, + currency: currency, + currencyDisplay: currencyDisplay, + currencySign: currencySign, useGrouping: useGrouping, + minimumIntegerDigits: minimumIntegerDigits, minimumFractionDigits: minimumFractionDigits, - maximumFractionDigits: maximumFractionDigits + maximumFractionDigits: maximumFractionDigits, + minimumSignificantDigits: minimumSignificantDigits, + maximumSignificantDigits: maximumSignificantDigits ) + guard next != formatSpec else { return } + formatSpec = next + formatter = Self.makeFormatter(next) + } + + // swiftlint:disable:next function_parameter_count + @objc(applyValue:direction:reduceMotion:fontSize:fontWeight:fontFamily:textColor:) + public func apply( + value: Double, + direction: String, + reduceMotion: String, + fontSize: CGFloat, + fontWeight: String, + fontFamily: String?, + textColor: UIColor? + ) { + let nextText = Self.text(value, formatter: formatter) + let changed = model.text != nextText + model.fontSize = fontSize > 0 ? fontSize : 48 model.weight = Self.weight(from: fontWeight) model.fontFamily = fontFamily @@ -85,11 +123,17 @@ public final class NumericTextSwiftUIHost: UIView { value: value, previous: lastValue ) - // The first render places the number; only later ones are transitions. - model.animates = lastValue != nil && Self.animates(reduceMotion: reduceMotion) - let changed = lastValue != value + // The first render places the number; later transitions are driven by what is actually drawn, + // not only by the Double. A format-only change such as `$1,000.00` -> `USD 1,000.00` therefore + // gets the same SwiftUI numericText transition even though the numeric value did not change. + model.animates = + lastValue != nil && + changed && + Self.animates(reduceMotion: reduceMotion) model.value = value lastValue = value + // Assign the animation trigger last, after countsDown/animates have been resolved for this frame. + model.text = nextText #if DEBUG if model.animates, changed, let host = hosting?.view { @@ -151,23 +195,117 @@ public final class NumericTextSwiftUIHost: UIView { } } - private static func format( + // MARK: - Formatting + + /// The formatting props, as `src/numberFormat.ts` resolved them. A digit bound of -1 means the + /// caller left it out and the style should supply its own. + private struct FormatSpec: Equatable { + var locale: String = "en-US" + var numberStyle: String = "decimal" + var currency: String = "" + var currencyDisplay: String = "symbol" + var currencySign: String = "standard" + var useGrouping: Bool = true + var minimumIntegerDigits: Int = -1 + var minimumFractionDigits: Int = -1 + var maximumFractionDigits: Int = -1 + var minimumSignificantDigits: Int = -1 + var maximumSignificantDigits: Int = -1 + } + + private static func text( _ value: Double, - locale: String, - useGrouping: Bool, - minimumFractionDigits: Int, - maximumFractionDigits: Int + formatter: NumberFormatter ) -> String { + formatter.string(from: NSNumber(value: value)) ?? String(value) + } + + private static func makeFormatter(_ spec: FormatSpec) -> NumberFormatter { let formatter = NumberFormatter() - formatter.locale = Locale(identifier: locale.isEmpty ? "en-US" : locale) - formatter.numberStyle = .decimal - formatter.usesGroupingSeparator = useGrouping - formatter.minimumFractionDigits = max(0, minimumFractionDigits) - formatter.maximumFractionDigits = max( - max(0, minimumFractionDigits), - maximumFractionDigits + formatter.locale = Locale(identifier: spec.locale.isEmpty ? "en-US" : spec.locale) + + let money = spec.numberStyle == "currency" && !spec.currency.isEmpty + formatter.numberStyle = style(spec, money: money) + if money { formatter.currencyCode = spec.currency } + formatter.usesGroupingSeparator = spec.useGrouping + + // Intl rounds halves away from zero; Foundation and ICU both default to half-even. Follow + // Intl, so `2.5` at zero decimals reads as `3` on iOS, on Android, and on the web fallback + // rather than as `3`, `2`, `2`. + formatter.roundingMode = .halfUp + + if spec.minimumIntegerDigits >= 0 { + formatter.minimumIntegerDigits = spec.minimumIntegerDigits + } + + if spec.minimumSignificantDigits >= 0 || spec.maximumSignificantDigits >= 0 { + let (low, high) = bounds( + spec.minimumSignificantDigits, + spec.maximumSignificantDigits, + defaultMinimum: 1, + defaultMaximum: 21 + ) + formatter.usesSignificantDigits = true + formatter.maximumSignificantDigits = high + formatter.minimumSignificantDigits = low + return formatter + } + + // Read before it is written: with the style and the code set, the formatter is already + // carrying the currency's own fraction digits (2 for USD, 0 for JPY, 3 for BHD), and that is + // exactly the default Intl would have applied. + let percent = !money && spec.numberStyle == "percent" + let defaultMaximum: Int + if money { + defaultMaximum = max(0, formatter.maximumFractionDigits) + } else if percent { + defaultMaximum = 0 + } else { + defaultMaximum = 3 + } + // A plain number is the only style that may drop a trailing zero, so it is the only one whose + // minimum differs from its maximum. + let defaultMinimum = (money || percent) ? defaultMaximum : 0 + + let (low, high) = bounds( + spec.minimumFractionDigits, + spec.maximumFractionDigits, + defaultMinimum: defaultMinimum, + defaultMaximum: defaultMaximum ) - return formatter.string(from: NSNumber(value: value)) ?? String(value) + formatter.maximumFractionDigits = high + formatter.minimumFractionDigits = low + return formatter + } + + private static func style(_ spec: FormatSpec, money: Bool) -> NumberFormatter.Style { + guard money else { + return spec.numberStyle == "percent" ? .percent : .decimal + } + switch spec.currencyDisplay { + case "code": return .currencyISOCode + default: return spec.currencySign == "accounting" ? .currencyAccounting : .currency + } + } + + /** + * ECMA-402's rule for resolving digit bounds, so the three implementations of this component + * round the same number to the same string. + * + * A bound that was left out is filled from the style, and a maximum below its minimum is + * clamped rather than rejected. `Intl` throws on that pair; a formatter that refuses to draw is + * worse than a number carrying one more decimal than was asked for. + */ + private static func bounds( + _ minimum: Int, + _ maximum: Int, + defaultMinimum: Int, + defaultMaximum: Int + ) -> (Int, Int) { + if minimum >= 0 && maximum >= 0 { return (minimum, max(minimum, maximum)) } + if minimum >= 0 { return (minimum, max(defaultMaximum, minimum)) } + if maximum >= 0 { return (min(defaultMinimum, maximum), maximum) } + return (defaultMinimum, defaultMaximum) } } @@ -230,7 +368,7 @@ private struct NumericTextRoot: View { .foregroundStyle(model.color) .numericTextTransition(countsDown: model.countsDown) .debugSliceProbe() - .animation(model.animates ? transitionAnimation : nil, value: model.value) + .animation(model.animates ? transitionAnimation : nil, value: model.text) .frame(maxWidth: .infinity, maxHeight: .infinity) .mask(edgeFadeMask) } diff --git a/ios/NumericTextView.mm b/ios/NumericTextView.mm index 4b6d53e..aab41d9 100644 --- a/ios/NumericTextView.mm +++ b/ios/NumericTextView.mm @@ -52,19 +52,30 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & const auto &next = *std::static_pointer_cast(props); // Every prop is forwarded on every update rather than diffed here. The SwiftUI side ignores an - // unchanged value on its own — its animation is scoped to `value` — and a partial forward is how - // a number ends up still drawn in the previous font after a style change. + // unchanged value on its own, and a partial forward is how a number ends up still drawn in the + // previous font after a style change. + // + // Formatting first: the value is drawn through it, so a change to both in one commit has to + // reach the formatter before it reaches the text. + [_host applyFormatWithLocale:RCTNSStringFromString(next.locale) + numberStyle:RCTNSStringFromString(next.numberStyle) + currency:RCTNSStringFromString(next.currency) + currencyDisplay:RCTNSStringFromString(next.currencyDisplay) + currencySign:RCTNSStringFromString(next.currencySign) + useGrouping:next.useGrouping + minimumIntegerDigits:next.minimumIntegerDigits + minimumFractionDigits:next.minimumFractionDigits + maximumFractionDigits:next.maximumFractionDigits + minimumSignificantDigits:next.minimumSignificantDigits + maximumSignificantDigits:next.maximumSignificantDigits]; + [_host applyValue:next.value - locale:RCTNSStringFromString(next.locale) - direction:RCTNSStringFromString(next.direction) - reduceMotion:RCTNSStringFromString(next.reduceMotion) - useGrouping:next.useGrouping - minimumFractionDigits:next.minimumFractionDigits - maximumFractionDigits:next.maximumFractionDigits - fontSize:next.fontSize - fontWeight:RCTNSStringFromString(next.fontWeight) - fontFamily:RCTNSStringFromString(next.fontFamily) - textColor:RCTUIColorFromSharedColor(next.textColor)]; + direction:RCTNSStringFromString(next.direction) + reduceMotion:RCTNSStringFromString(next.reduceMotion) + fontSize:next.fontSize + fontWeight:RCTNSStringFromString(next.fontWeight) + fontFamily:RCTNSStringFromString(next.fontFamily) + textColor:RCTUIColorFromSharedColor(next.textColor)]; [super updateProps:props oldProps:oldProps]; } diff --git a/src/NumericTextFallback.tsx b/src/NumericTextFallback.tsx index 2fdbf1f..3304c38 100644 --- a/src/NumericTextFallback.tsx +++ b/src/NumericTextFallback.tsx @@ -1,30 +1,16 @@ import { memo } from 'react'; import { Text } from 'react-native'; +import { accessibilityPropsOf } from './accessibilityProps'; +import { DEFAULT_LOCALE, formatNumber, resolveFormat } from './numberFormat'; import type { NumericTextProps } from './types'; -/** - * The number, formatted, with no animation. - * - * Used on platforms without a native renderer (currently web). It formats identically to the - * native path so snapshots and web renders keep the same number formatting; only the transition is - * missing. - */ -function NumericTextFallbackImpl({ - value, - locale = 'en-US', - minimumFractionDigits = 0, - maximumFractionDigits = 3, - useGrouping = true, - style, - testID, -}: NumericTextProps) { - const formatted = value.toLocaleString(locale, { - minimumFractionDigits, - maximumFractionDigits, - useGrouping, - }); +/** Static fallback for platforms without the native transition renderer. */ +function NumericTextFallbackImpl(props: NumericTextProps) { + const { value, locale = DEFAULT_LOCALE, style, testID } = props; + const formatted = formatNumber(value, locale, resolveFormat(props)); + return ( - + {formatted} ); diff --git a/src/NumericTextView.native.tsx b/src/NumericTextView.native.tsx index c6beb2c..5d6969d 100644 --- a/src/NumericTextView.native.tsx +++ b/src/NumericTextView.native.tsx @@ -1,41 +1,37 @@ import { memo, useEffect, useRef, useState } from 'react'; import NumericTextViewNativeComponent from './NumericTextViewNativeComponent'; +import { accessibilityPropsOf } from './accessibilityProps'; import { measureBox, widest, type Box } from './measureBox'; +import { + DEFAULT_LOCALE, + formatNumber, + nativeFormatProps, + resolveFormat, +} from './numberFormat'; import { resolveTextStyle } from './resolveTextStyle'; import type { NumericTextProps } from './types'; /** * One component, one prop shape, two native renderers. * - * Android draws the transition itself (`android/…/NumericTextView.kt`: a spring per digit column). - * iOS asks SwiftUI for its own `.contentTransition(.numericText())` - * (`ios/NumericTextSwiftUIHost.swift`) — the behaviour the Android renderer is measured against, so - * there is nothing to reimplement there. Web and anything else without a native view resolve - * `./NumericTextView` to the static fallback instead of this file. - * - * `animationDuration` reaches Android only; SwiftUI's numeric transition is a spring with no - * duration to set. Both sides otherwise read the same props, including the text properties pulled - * out of `style` — each renderer draws its own glyphs, so it needs them as props. + * Android draws the transition itself; iOS delegates the numeric transition to SwiftUI. Formatting + * is passed as props rather than as a finished string because each renderer needs the numeric + * structure where it draws it. JS reproduces the format only to reserve a safe layout box. */ -function NumericTextViewImpl({ - value, - locale = 'en-US', - direction = 'automatic', - animationDuration = 80, - reduceMotion = 'system', - minimumFractionDigits = 0, - maximumFractionDigits = 3, - useGrouping = true, - style, - testID, -}: NumericTextProps) { - const text = resolveTextStyle(style); +function NumericTextViewImpl(props: NumericTextProps) { + const { + value, + locale = DEFAULT_LOCALE, + direction = 'automatic', + animationDuration = 80, + reduceMotion = 'system', + style, + testID, + } = props; - const formatted = value.toLocaleString(locale, { - minimumFractionDigits, - maximumFractionDigits, - useGrouping, - }); + const text = resolveTextStyle(style); + const format = resolveFormat(props); + const formatted = formatNumber(value, locale, format); const box = useShrinkHeldBox( measureBox(formatted, text.fontSize), Math.max(animationDuration, 500) + 400 @@ -43,14 +39,13 @@ function NumericTextViewImpl({ return ( = held.minWidth && target.minHeight >= held.minHeight; + targetMinWidth >= held.minWidth && targetMinHeight >= held.minHeight; const settled = - target.minWidth === held.minWidth && target.minHeight === held.minHeight; + targetMinWidth === held.minWidth && targetMinHeight === held.minHeight; useEffect(() => { if (settled) return; if (grew) { - setHeld(targetRef.current); + setHeld({ minWidth: targetMinWidth, minHeight: targetMinHeight }); return; } - const timer = setTimeout(() => setHeld(targetRef.current), holdMs); + + // Capture the box this timer belongs to. React Native can delay/coalesce JS timers; if a newer + // formatted value arrives before this callback gets CPU time, targetRef.current already points + // at that newer (possibly much narrower) box. Shrinking to targetRef.current here clipped the + // outgoing raster during format changes such as `1,000% -> ¥999`. A stale release may only + // commit when its own target is still current. + const shrinkTo = { minWidth: targetMinWidth, minHeight: targetMinHeight }; + const timer = setTimeout(() => { + if (sameBox(targetRef.current, shrinkTo)) { + setHeld(shrinkTo); + } + }, holdMs); return () => clearTimeout(timer); - }, [settled, grew, target.minWidth, target.minHeight, holdMs]); + }, [settled, grew, targetMinWidth, targetMinHeight, holdMs]); return settled ? target : widest(target, held); } -/** - * Re-renders only when a prop actually differs. The value changes far more often than anything - * else here, and every render of a parent would otherwise walk this whole tree to produce an - * identical element. - */ export const NumericTextView = memo(NumericTextViewImpl); NumericTextView.displayName = 'NumericTextView'; diff --git a/src/NumericTextViewNativeComponent.ts b/src/NumericTextViewNativeComponent.ts index 64cb8c3..059c514 100644 --- a/src/NumericTextViewNativeComponent.ts +++ b/src/NumericTextViewNativeComponent.ts @@ -10,10 +10,22 @@ interface NativeProps extends ViewProps { readonly direction?: string; readonly locale?: string; readonly animationDuration?: Double; + readonly reduceMotion?: string; + + // Formatting. Flat scalars rather than one object: this is the shape codegen carries well, and + // `src/numberFormat.ts` is where the `Intl`-shaped prop is resolved down to them. + readonly numberStyle?: string; + readonly currency?: string; + readonly currencyDisplay?: string; + readonly currencySign?: string; readonly useGrouping?: boolean; + /** A digit count, or -1 for "the format's own default"; see `DIGITS_UNSET`. */ + readonly minimumIntegerDigits?: Int32; readonly minimumFractionDigits?: Int32; readonly maximumFractionDigits?: Int32; - readonly reduceMotion?: string; + readonly minimumSignificantDigits?: Int32; + readonly maximumSignificantDigits?: Int32; + readonly fontSize?: Float; readonly fontWeight?: string; readonly fontFamily?: string; diff --git a/src/__tests__/accessibilityProps.test.ts b/src/__tests__/accessibilityProps.test.ts new file mode 100644 index 0000000..3bcb306 --- /dev/null +++ b/src/__tests__/accessibilityProps.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from '@jest/globals'; +import { accessibilityPropsOf } from '../accessibilityProps'; + +describe('accessibilityPropsOf', () => { + it('forwards accessibility semantics without leaking numeric props', () => { + const forwarded = accessibilityPropsOf({ + value: 1000, + currency: 'USD', + format: { style: 'currency', currency: 'USD' }, + accessible: true, + accessibilityLabel: 'Account balance', + accessibilityHint: 'Current available balance', + accessibilityRole: 'text', + accessibilityLiveRegion: 'polite', + }); + + expect(forwarded).toMatchObject({ + accessible: true, + accessibilityLabel: 'Account balance', + accessibilityHint: 'Current available balance', + accessibilityRole: 'text', + accessibilityLiveRegion: 'polite', + }); + expect(forwarded).not.toHaveProperty('value'); + expect(forwarded).not.toHaveProperty('currency'); + expect(forwarded).not.toHaveProperty('format'); + }); +}); diff --git a/src/__tests__/measureBox.test.ts b/src/__tests__/measureBox.test.ts index 86e8e67..b2fbba3 100644 --- a/src/__tests__/measureBox.test.ts +++ b/src/__tests__/measureBox.test.ts @@ -24,6 +24,27 @@ describe('widthInEm', () => { expect(widthInEm('-1')).toBeGreaterThan(widthInEm('1')); expect(widthInEm('−1')).toBe(widthInEm('-1')); }); + + it('charges a currency symbol as a glyph rather than as punctuation', () => { + // `$` measures 0.6064 em in the bundled Regular and a comma 0.2541. Charging the first at the + // second's width under-reserves the box by most of a digit, which the headroom cannot absorb + // once a format carries several of them. + for (const symbol of ['$', '€', '£', '¥', '₹', '%']) { + expect(widthInEm(symbol)).toBeGreaterThan(widthInEm(',')); + } + }); + + it('charges the letters of an ISO currency code', () => { + expect(widthInEm('USD')).toBeGreaterThan(3 * widthInEm(',')); + }); + + it('keeps the spaces a locale groups with narrow', () => { + // fr-FR groups with a narrow no-break space and puts a no-break space before the symbol. + // Charging either as a glyph would reserve most of a digit for a gap. + for (const space of ['\u00a0', '\u2007', '\u2008', '\u2009', '\u202f']) { + expect(widthInEm(space)).toBe(widthInEm(',')); + } + }); }); describe('measureBox', () => { diff --git a/src/__tests__/numberFormat.test.ts b/src/__tests__/numberFormat.test.ts new file mode 100644 index 0000000..e4cf589 --- /dev/null +++ b/src/__tests__/numberFormat.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from '@jest/globals'; +import { + DIGITS_UNSET, + formatNumber, + intlOptions, + nativeFormatProps, + normalizeFormat, + resolveFormat, +} from '../numberFormat'; + +describe('resolveFormat', () => { + it('reads the currency shorthand as a currency style', () => { + expect(resolveFormat({ currency: 'USD' })).toEqual({ + style: 'currency', + currency: 'USD', + }); + }); + + it('lets format override the shorthands where they overlap', () => { + expect( + resolveFormat({ + currency: 'USD', + useGrouping: true, + format: { currency: 'EUR', useGrouping: false }, + }) + ).toEqual({ + style: 'currency', + currency: 'EUR', + useGrouping: false, + }); + }); + + it('leaves an absent prop absent rather than defaulting it', () => { + expect(resolveFormat({})).toEqual({}); + }); +}); + +describe('normalizeFormat', () => { + it('normalizes a currency code before both JS and native see it', () => { + expect( + normalizeFormat({ style: 'currency', currency: 'usd' }) + ).toMatchObject({ + style: 'currency', + currency: 'USD', + }); + }); + + it('drops a malformed currency code instead of letting platforms disagree', () => { + expect( + normalizeFormat({ style: 'currency', currency: 'NOPE' }) + ).toMatchObject({ + style: 'decimal', + }); + }); + + it('keeps accounting symbol-only', () => { + const normalized = normalizeFormat({ + style: 'currency', + currency: 'USD', + currencyDisplay: 'code', + currencySign: 'accounting', + }); + expect(normalized.currencyDisplay).toBe('code'); + expect(normalized.currencySign).toBe('standard'); + }); + + it('clamps digit bounds to the Intl contract', () => { + expect( + normalizeFormat({ + minimumIntegerDigits: 0, + minimumFractionDigits: -1, + maximumFractionDigits: 500, + minimumSignificantDigits: 0, + maximumSignificantDigits: 99, + }) + ).toMatchObject({ + minimumIntegerDigits: 1, + minimumFractionDigits: 0, + maximumFractionDigits: 100, + minimumSignificantDigits: 1, + maximumSignificantDigits: 21, + }); + }); + + it('never leaves a maximum below its minimum', () => { + expect( + normalizeFormat({ + minimumFractionDigits: 4, + maximumFractionDigits: 1, + }) + ).toMatchObject({ + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }); + }); +}); + +describe('formatNumber', () => { + it('formats a plain number to at most three decimals', () => { + expect(formatNumber(1234.5678, 'en-US', {})).toBe('1,234.568'); + }); + + it('takes the currencys own fraction digits when none were given', () => { + expect( + formatNumber(1234.5, 'en-US', resolveFormat({ currency: 'USD' })) + ).toBe('$1,234.50'); + expect( + formatNumber(1234.5, 'en-US', resolveFormat({ currency: 'JPY' })) + ).toBe('¥1,235'); + }); + + it('puts the symbol where the locale puts it', () => { + expect( + formatNumber(1234.5, 'de-DE', resolveFormat({ currency: 'EUR' })) + ).toBe('1.234,50\u00a0€'); + }); + + it('writes the currency as an ISO code on request', () => { + expect( + formatNumber(1234.5, 'en-US', { + style: 'currency', + currency: 'USD', + currencyDisplay: 'code', + }) + ).toBe('USD\u00a01,234.50'); + }); + + it('brackets a negative amount in the accounting sign', () => { + expect( + formatNumber(-1234.5, 'en-US', { + style: 'currency', + currency: 'USD', + currencySign: 'accounting', + }) + ).toBe('($1,234.50)'); + }); + + it('does not apply accounting to code display', () => { + expect( + formatNumber(-1234.5, 'en-US', { + style: 'currency', + currency: 'USD', + currencyDisplay: 'code', + currencySign: 'accounting', + }) + ).toBe('-USD\u00a01,234.50'); + }); + + it('multiplies a percentage by a hundred and drops its decimals', () => { + expect(formatNumber(0.425, 'en-US', { style: 'percent' })).toBe('43%'); + }); + + it('pads to a minimum integer width', () => { + expect(formatNumber(9, 'en-US', { minimumIntegerDigits: 2 })).toBe('09'); + }); + + it('rounds a half away from zero', () => { + expect(formatNumber(2.5, 'en-US', { maximumFractionDigits: 0 })).toBe('3'); + expect(formatNumber(-2.5, 'en-US', { maximumFractionDigits: 0 })).toBe( + '-3' + ); + }); + + it('falls back to a plain number on a malformed currency', () => { + expect( + formatNumber(12, 'en-US', { style: 'currency', currency: 'NOPE' }) + ).toBe('12'); + }); + + it('treats a currency style with no code as a plain number', () => { + expect(formatNumber(1234.5, 'en-US', { style: 'currency' })).toBe( + '1,234.5' + ); + }); + + it('does not fall back to a short JS string for an oversized fraction bound', () => { + const text = formatNumber(1, 'en-US', { + minimumFractionDigits: 101, + maximumFractionDigits: 101, + }); + expect(text.split('.')[1]).toHaveLength(100); + }); +}); + +describe('intlOptions', () => { + it('never asks for a maximum below its minimum', () => { + const options = intlOptions({ + minimumFractionDigits: 4, + maximumFractionDigits: 1, + }); + expect(options.maximumFractionDigits).toBe(4); + expect(() => (1).toLocaleString('en-US', options)).not.toThrow(); + }); + + it('passes a bound through untouched when it is already valid', () => { + expect(intlOptions({ maximumFractionDigits: 1 })).not.toHaveProperty( + 'minimumFractionDigits' + ); + expect( + intlOptions({ maximumFractionDigits: 1 }).maximumFractionDigits + ).toBe(1); + }); +}); + +describe('nativeFormatProps', () => { + it('marks an absent bound so the renderer can apply the same default', () => { + expect(nativeFormatProps({})).toEqual({ + numberStyle: 'decimal', + currency: '', + currencyDisplay: 'symbol', + currencySign: 'standard', + useGrouping: true, + minimumIntegerDigits: DIGITS_UNSET, + minimumFractionDigits: DIGITS_UNSET, + maximumFractionDigits: DIGITS_UNSET, + minimumSignificantDigits: DIGITS_UNSET, + maximumSignificantDigits: DIGITS_UNSET, + }); + }); + + it('carries a currency down as flat scalars', () => { + const props = nativeFormatProps(resolveFormat({ currency: 'EUR' })); + expect(props.numberStyle).toBe('currency'); + expect(props.currency).toBe('EUR'); + }); + + it('drops a currency style that has no code, as the formatters do', () => { + const props = nativeFormatProps({ style: 'currency' }); + expect(props.numberStyle).toBe('decimal'); + expect(props.currency).toBe(''); + }); + + it('hands native the same clamped bound JS uses', () => { + const props = nativeFormatProps({ + minimumFractionDigits: 101, + maximumFractionDigits: 500, + }); + expect(props.minimumFractionDigits).toBe(100); + expect(props.maximumFractionDigits).toBe(100); + }); +}); diff --git a/src/accessibilityProps.ts b/src/accessibilityProps.ts new file mode 100644 index 0000000..b3deea7 --- /dev/null +++ b/src/accessibilityProps.ts @@ -0,0 +1,20 @@ +import type { NumericTextAccessibilityProps, NumericTextProps } from './types'; + +/** Accessibility is a view concern, not part of numeric formatting. Keep the two prop surfaces apart. */ +export function accessibilityPropsOf( + props: NumericTextProps +): NumericTextAccessibilityProps { + return { + accessible: props.accessible, + accessibilityLabel: props.accessibilityLabel, + accessibilityHint: props.accessibilityHint, + accessibilityRole: props.accessibilityRole, + accessibilityLiveRegion: props.accessibilityLiveRegion, + importantForAccessibility: props.importantForAccessibility, + screenReaderFocusable: props.screenReaderFocusable, + accessibilityLabelledBy: props.accessibilityLabelledBy, + accessibilityElementsHidden: props.accessibilityElementsHidden, + accessibilityViewIsModal: props.accessibilityViewIsModal, + accessibilityLanguage: props.accessibilityLanguage, + }; +} diff --git a/src/index.tsx b/src/index.tsx index 2c8e758..cd89bb1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,4 @@ export { NumericTextView } from './NumericTextView'; /** Preferred name. `NumericTextView` is the same component, kept for the original import path. */ export { NumericTextView as NumericText } from './NumericTextView'; -export type { NumericTextProps } from './types'; +export type { NumericTextFormat, NumericTextProps } from './types'; diff --git a/src/measureBox.ts b/src/measureBox.ts index e6e494e..a2e2c92 100644 --- a/src/measureBox.ts +++ b/src/measureBox.ts @@ -8,7 +8,9 @@ * * The estimate is per character rather than a flat average because the bundled font's tabular * digits and its separators differ by more than a factor of two, and a four-digit grouped number - * is a quarter separator by character count. + * is a quarter separator by character count. Once a number can carry a currency (`$1,234.56`, + * `1.234,56 €`, `USD 1,234.56`) the spread is wider still, so symbols and letters are charged + * separately from punctuation rather than being lumped in with the comma. */ /** Advance widths as a fraction of font size, from the bundled Sunghyun Sans with `tnum` on. */ @@ -16,9 +18,23 @@ const DIGIT_EM = 0.6167; const SEPARATOR_EM = 0.2541; const SIGN_EM = 0.36; +/** Currency symbols, the percent sign, and the letters of an ISO currency code. */ +const GLYPH_EM = 0.62; + /** Room for the transition's own overspill: a dying glyph drifts outward and carries a blur halo. */ const HEADROOM_EM = 0.5; +/** + * JS and native do not necessarily shape with the same face. iOS' default rounded system font in + * particular accumulates more advance than the bundled-font constants above on a long currency + * line. Fabric cannot ask that native Text for its intrinsic width, so reserve a small cumulative + * allowance per Unicode scalar instead of hiding all metric drift in one fixed headroom value. + * + * 0.06 em per scalar is the measured margin that keeps `BHD 1,000.000` at fontSize 38 from + * truncating on iOS while remaining modest for the short, digit-only cases this component targets. + */ +const NATIVE_METRIC_DRIFT_EM = 0.06; + /** Falls back to this when no fontSize is given, matching the renderer's own default. */ const DEFAULT_FONT_SIZE = 48; @@ -36,18 +52,57 @@ export function widthInEm(formatted: string): number { for (const ch of formatted) { if (ch >= '0' && ch <= '9') em += DIGIT_EM; else if (ch === '-' || ch === '−' || ch === '+') em += SIGN_EM; - else em += SEPARATOR_EM; + else if (isNarrow(ch)) em += SEPARATOR_EM; + else em += GLYPH_EM; } return em; } +/** + * Whether [ch] is punctuation or a space rather than something with ink to spare. + * + * The separators a formatter emits are few and known: the comma, the stop, the apostrophe forms, + * the middle dot, the Arabic marks, and the several fixed-width spaces a French or Swiss locale + * groups with. They are therefore listed rather than derived. Anything else in a formatted number is a + * digit, a sign, or a symbol or letter that carries a currency, and those are charged as glyphs. + */ +function isNarrow(ch: string): boolean { + return NARROW.has(ch); +} + +const NARROW = new Set([ + ',', + '.', + "'", + '’', // right single quote, the Swiss grouping separator + '·', // middle dot + '٫', // Arabic decimal separator + '٬', // Arabic thousands separator + ',', // fullwidth comma + '.', // fullwidth stop + '(', + ')', + ' ', + ' ', // no-break space + ' ', // figure space + ' ', // punctuation space + ' ', // thin space + ' ', // narrow no-break space +]); + export function measureBox( formatted: string, fontSize: number | undefined ): Box { const size = fontSize ?? DEFAULT_FONT_SIZE; + const scalarCount = Array.from(formatted).length; return { - minWidth: Math.ceil((widthInEm(formatted) + HEADROOM_EM) * size), + minWidth: Math.ceil( + (widthInEm(formatted) + + HEADROOM_EM + + scalarCount * NATIVE_METRIC_DRIFT_EM) * + size + ), minHeight: Math.ceil(size * 1.5), }; } diff --git a/src/numberFormat.ts b/src/numberFormat.ts new file mode 100644 index 0000000..9c82888 --- /dev/null +++ b/src/numberFormat.ts @@ -0,0 +1,255 @@ +import type { NumericTextFormat, NumericTextProps } from './types'; + +/** + * One description of "what this number should read as", shared by the web fallback, the JS width + * estimate, and the props handed to the native renderers. + */ + +export const DIGITS_UNSET = -1; +export const DEFAULT_LOCALE = 'en-US'; + +const MIN_INTEGER_DIGITS = 1; +const MAX_INTEGER_DIGITS = 21; +const MIN_FRACTION_DIGITS = 0; +const MAX_FRACTION_DIGITS = 100; +const MIN_SIGNIFICANT_DIGITS = 1; +const MAX_SIGNIFICANT_DIGITS = 21; + +type FormatProps = Pick< + NumericTextProps, + | 'format' + | 'currency' + | 'useGrouping' + | 'minimumFractionDigits' + | 'maximumFractionDigits' +>; + +export type NativeFormatProps = { + numberStyle: string; + currency: string; + currencyDisplay: string; + currencySign: string; + useGrouping: boolean; + minimumIntegerDigits: number; + minimumFractionDigits: number; + maximumFractionDigits: number; + minimumSignificantDigits: number; + maximumSignificantDigits: number; +}; + +export function resolveFormat(props: FormatProps): NumericTextFormat { + const { + format, + currency, + useGrouping, + minimumFractionDigits, + maximumFractionDigits, + } = props; + + const shorthand: NumericTextFormat = {}; + if (currency !== undefined) { + shorthand.style = 'currency'; + shorthand.currency = currency; + } + if (useGrouping !== undefined) shorthand.useGrouping = useGrouping; + if (minimumFractionDigits !== undefined) { + shorthand.minimumFractionDigits = minimumFractionDigits; + } + if (maximumFractionDigits !== undefined) { + shorthand.maximumFractionDigits = maximumFractionDigits; + } + + return format ? { ...shorthand, ...format } : shorthand; +} + +/** + * Canonicalizes the public format before any formatter sees it. + * + * Native prop setters cannot throw the way `Intl.NumberFormat` can. Letting each platform decide + * what to do with an invalid digit bound therefore creates a much worse failure mode than a bad + * string: JS can measure a short fallback while native draws tens or hundreds of digits. We keep + * this component non-throwing, but clamp the numeric options to ECMA-402's supported ranges and + * hand the exact same values to JS, Android and iOS. + * + * The public currency display contract is deliberately `symbol | code`. Any unsupported runtime + * value degrades to `symbol` before JS or either native formatter sees it, so an untyped caller + * cannot accidentally opt into a platform-specific affix model. + */ +export function normalizeFormat(format: NumericTextFormat): NumericTextFormat { + const currency = normalizeCurrency(format.currency); + const style = + format.style === 'currency' && currency + ? 'currency' + : format.style === 'percent' + ? 'percent' + : 'decimal'; + + const currencyDisplay = format.currencyDisplay === 'code' ? 'code' : 'symbol'; + const currencySign = + currencyDisplay === 'symbol' && format.currencySign === 'accounting' + ? 'accounting' + : 'standard'; + + const [minimumFractionDigits, maximumFractionDigits] = normalizeBounds( + format.minimumFractionDigits, + format.maximumFractionDigits, + MIN_FRACTION_DIGITS, + MAX_FRACTION_DIGITS + ); + const [minimumSignificantDigits, maximumSignificantDigits] = normalizeBounds( + format.minimumSignificantDigits, + format.maximumSignificantDigits, + MIN_SIGNIFICANT_DIGITS, + MAX_SIGNIFICANT_DIGITS + ); + + return { + style, + ...(style === 'currency' ? { currency } : {}), + ...(style === 'currency' ? { currencyDisplay, currencySign } : {}), + useGrouping: format.useGrouping ?? true, + ...defined( + 'minimumIntegerDigits', + normalizeDigitBound( + format.minimumIntegerDigits, + MIN_INTEGER_DIGITS, + MAX_INTEGER_DIGITS + ) + ), + ...defined('minimumFractionDigits', minimumFractionDigits), + ...defined('maximumFractionDigits', maximumFractionDigits), + ...defined('minimumSignificantDigits', minimumSignificantDigits), + ...defined('maximumSignificantDigits', maximumSignificantDigits), + }; +} + +function normalizeCurrency(value: string | undefined): string | undefined { + if (!value || !/^[A-Za-z]{3}$/.test(value)) return undefined; + return value.toUpperCase(); +} + +function normalizeBounds( + minValue: number | undefined, + maxValue: number | undefined, + lower: number, + upper: number +): [number | undefined, number | undefined] { + const min = normalizeDigitBound(minValue, lower, upper); + let max = normalizeDigitBound(maxValue, lower, upper); + if (min !== undefined && max !== undefined && max < min) max = min; + return [min, max]; +} + +function normalizeDigitBound( + value: number | undefined, + lower: number, + upper: number +): number | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + return Math.min(upper, Math.max(lower, Math.floor(value))); +} + +function defined( + key: K, + value: NumericTextFormat[K] | undefined +): Pick | Record { + return value === undefined + ? {} + : ({ [key]: value } as Pick); +} + +function isCurrency(format: NumericTextFormat): boolean { + return format.style === 'currency' && !!format.currency; +} + +export function intlOptions( + format: NumericTextFormat +): Intl.NumberFormatOptions { + const normalized = normalizeFormat(format); + const options: Intl.NumberFormatOptions = { + useGrouping: normalized.useGrouping, + }; + + if (isCurrency(normalized)) { + options.style = 'currency'; + options.currency = normalized.currency; + options.currencyDisplay = normalized.currencyDisplay; + options.currencySign = normalized.currencySign; + } else if (normalized.style === 'percent') { + options.style = 'percent'; + } + + if (normalized.minimumIntegerDigits !== undefined) { + options.minimumIntegerDigits = normalized.minimumIntegerDigits; + } + + assignBounds( + options, + 'minimumSignificantDigits', + 'maximumSignificantDigits', + normalized.minimumSignificantDigits, + normalized.maximumSignificantDigits + ); + assignBounds( + options, + 'minimumFractionDigits', + 'maximumFractionDigits', + normalized.minimumFractionDigits, + normalized.maximumFractionDigits + ); + + return options; +} + +function assignBounds( + options: Intl.NumberFormatOptions, + minKey: 'minimumFractionDigits' | 'minimumSignificantDigits', + maxKey: 'maximumFractionDigits' | 'maximumSignificantDigits', + min: number | undefined, + max: number | undefined +): void { + if (min !== undefined) options[minKey] = min; + if (max !== undefined) options[maxKey] = max; +} + +export function nativeFormatProps( + format: NumericTextFormat +): NativeFormatProps { + const normalized = normalizeFormat(format); + const currency = isCurrency(normalized); + return { + numberStyle: currency + ? 'currency' + : normalized.style === 'percent' + ? 'percent' + : 'decimal', + currency: currency ? normalized.currency! : '', + currencyDisplay: normalized.currencyDisplay ?? 'symbol', + currencySign: normalized.currencySign ?? 'standard', + useGrouping: normalized.useGrouping ?? true, + minimumIntegerDigits: normalized.minimumIntegerDigits ?? DIGITS_UNSET, + minimumFractionDigits: normalized.minimumFractionDigits ?? DIGITS_UNSET, + maximumFractionDigits: normalized.maximumFractionDigits ?? DIGITS_UNSET, + minimumSignificantDigits: + normalized.minimumSignificantDigits ?? DIGITS_UNSET, + maximumSignificantDigits: + normalized.maximumSignificantDigits ?? DIGITS_UNSET, + }; +} + +export function formatNumber( + value: number, + locale: string, + format: NumericTextFormat +): string { + const normalized = normalizeFormat(format); + try { + return value.toLocaleString(locale, intlOptions(normalized)); + } catch { + try { + return value.toLocaleString(locale); + } catch { + return value.toLocaleString(DEFAULT_LOCALE); + } + } +} diff --git a/src/types.ts b/src/types.ts index 87ca465..f36ad86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,51 +1,114 @@ -import type { StyleProp, TextStyle } from 'react-native'; +import type { AccessibilityProps, StyleProp, TextStyle } from 'react-native'; /** - * A number that animates between values the way SwiftUI's - * `.contentTransition(.numericText())` does: each digit column rolls on its own spring, digits - * that appear or disappear are born and die rather than sliding, and a rapid run of changes - * carries its momentum instead of restarting. + * How to turn the value into the number that is drawn: a deliberately small subset of + * `Intl.NumberFormatOptions`, resolved natively by `NumberFormatter` on iOS and `android.icu` on + * Android. + * + * Every option kept here must have a stable transition model as well as a formatting meaning. + * Localized currency names are intentionally deferred: the affix itself can change spelling with + * the value (`dollar`/`dollars`), while the current engine treats affixes as stable text. Compact + * notation and narrow currency symbols remain out for the same cross-platform/CLDR reasons as + * before. + * + * Rounding is fixed at half-away-from-zero on both platforms, matching `Intl`'s default. */ -export type NumericTextProps = { +export type NumericTextFormat = { + /** + * `'currency'` needs [currency] set and falls back to `'decimal'` without it. `'percent'` + * multiplies by 100 and appends the locale's percent sign. + */ + style?: 'decimal' | 'currency' | 'percent'; + + /** ISO 4217 code (`'USD'`, `'EUR'`, `'JPY'`). */ + currency?: string; + + /** Currency symbol (`$1,234.56`) or ISO code (`USD 1,234.56`). */ + currencyDisplay?: 'symbol' | 'code'; + + /** + * How to write a negative amount. `'accounting'` is applied only with + * `currencyDisplay: 'symbol'`; code display always uses the standard sign form. + */ + currencySign?: 'standard' | 'accounting'; + + /** Group separators (`1,000` vs `1000`). */ + useGrouping?: boolean; + + /** Pads with leading zeros to at least this many integer digits. */ + minimumIntegerDigits?: number; + + /** + * Fraction bounds. Left out, decimal uses 0..3, percent uses 0, and currency uses its own + * default fraction count (for example USD 2, JPY 0, BHD 3). + */ + minimumFractionDigits?: number; + maximumFractionDigits?: number; + + /** Significant-digit bounds; when present they take precedence over fraction bounds. */ + minimumSignificantDigits?: number; + maximumSignificantDigits?: number; +}; + +export type NumericTextAccessibilityProps = Pick< + AccessibilityProps, + | 'accessible' + | 'accessibilityLabel' + | 'accessibilityHint' + | 'accessibilityRole' + | 'accessibilityLiveRegion' + | 'importantForAccessibility' + | 'screenReaderFocusable' + | 'accessibilityLabelledBy' + | 'accessibilityElementsHidden' + | 'accessibilityViewIsModal' + | 'accessibilityLanguage' +>; + +/** + * A number that animates between values the way SwiftUI's `.contentTransition(.numericText())` + * does: each digit column rolls on its own spring and rapid changes retain their motion. + */ +export type NumericTextProps = NumericTextAccessibilityProps & { /** The number to display. Changing it animates; the first render does not. */ value: number; /** - * BCP-47 tag deciding grouping and decimal marks — `'en-US'` → `1,234.5`, `'de-DE'` → `1.234,5`. - * Defaults to `'en-US'` rather than the device locale, so a layout does not change shape - * depending on whose phone it renders on. + * BCP-47 tag deciding grouping and decimal marks. Defaults to `'en-US'` rather than the device + * locale so a layout does not silently change with the device language. */ locale?: string; + /** Everything about the shape of the number. */ + format?: NumericTextFormat; + + /** Shorthand for `format={{ style: 'currency', currency }}`. */ + currency?: string; + /** - * Which way digits roll. `'automatic'` rolls up when the value grows and down when it shrinks, - * which is what SwiftUI does; the other two force a direction regardless of the value. + * Which way digits roll. `'automatic'` rolls up when the value grows and down when it shrinks. */ direction?: 'automatic' | 'up' | 'down'; - /** - * Android only. Nominal duration in ms; scales the springs rather than clamping them to an - * exact time. SwiftUI owns the animation timing on iOS. - */ + /** Android only. Nominal spring duration in ms. */ animationDuration?: number; /** - * `'system'` follows the OS reduce-motion setting and cuts to the new value when it is on. - * `'always'` never animates; `'never'` animates even when the user asked the system not to — - * which is worth a reason before you reach for it. + * `'system'` follows the OS reduce-motion setting, `'always'` disables animation, and `'never'` + * animates regardless of the system setting. */ reduceMotion?: 'system' | 'always' | 'never'; + /** Shorthand for the same field of [format]. */ minimumFractionDigits?: number; + /** Shorthand for the same field of [format]. */ maximumFractionDigits?: number; - - /** Group separators (`1,000` vs `1000`). */ + /** Shorthand for the same field of [format]. */ useGrouping?: boolean; /** - * `fontSize`, `fontWeight`, `fontFamily` and `color` are read out and handed to the native - * renderer, which draws the glyphs itself; everything else applies to the view as usual. When - * omitted, native font size defaults to 48 and color to black. + * `fontSize`, `fontWeight`, `fontFamily` and `color` are handed to the native renderer; the rest + * applies to the view as usual. Native font size defaults to 48 and color to black. */ style?: StyleProp;