From d742335613b4a0b4f76970f9dc87fe47a3500e38 Mon Sep 17 00:00:00 2001 From: Giulio Amato Date: Mon, 17 Aug 2026 11:37:14 +0200 Subject: [PATCH] feat: add validated native currency and percent formatting Validated successor to #4, originally contributed by @Amanfromearth. Adds the Intl-shaped format contract, native iOS/Android formatting, currency/percent transitions, format retarget handling, regression coverage, and the FormatLab validation harness. --- .agent/tools/subset_font.sh | 18 +- README.md | 122 ++++- .../main/assets/fonts/SunghyunSans-Black.ttf | Bin 11008 -> 33840 bytes .../main/assets/fonts/SunghyunSans-Bold.ttf | Bin 11680 -> 34976 bytes .../assets/fonts/SunghyunSans-ExtraBold.ttf | Bin 11560 -> 35388 bytes .../assets/fonts/SunghyunSans-ExtraLight.ttf | Bin 10788 -> 33148 bytes .../main/assets/fonts/SunghyunSans-Light.ttf | Bin 11216 -> 34840 bytes .../main/assets/fonts/SunghyunSans-Medium.ttf | Bin 11028 -> 34156 bytes .../assets/fonts/SunghyunSans-Regular.ttf | Bin 11108 -> 34096 bytes .../assets/fonts/SunghyunSans-SemiBold.ttf | Bin 11252 -> 34592 bytes .../main/assets/fonts/SunghyunSans-Thin.ttf | Bin 10712 -> 32836 bytes .../com/numerictext/NumericTextFormatter.kt | 250 +++++++++ .../com/numerictext/NumericTextTimeline.kt | 511 +++++++++++++----- .../com/numerictext/NumericTextTypesetter.kt | 34 ++ .../java/com/numerictext/NumericTextView.kt | 230 ++++---- .../com/numerictext/NumericTextViewManager.kt | 161 +++++- .../java/com/numerictext/TransitionLogic.kt | 256 +++++++-- .../com/numerictext/FormatGeometryPinTest.kt | 275 ++++++++++ .../numerictext/SemanticTokenizationTest.kt | 95 ++++ .../com/numerictext/TransitionLogicTest.kt | 236 +++++++- .../UsdCodeRepeatTransitionTest.kt | 196 +++++++ example/src/FormatLab.tsx | 415 ++++++++++++++ ios/NumericTextSwiftUIHost.swift | 196 ++++++- ios/NumericTextView.mm | 35 +- src/NumericTextFallback.tsx | 30 +- src/NumericTextView.native.tsx | 92 ++-- src/NumericTextViewNativeComponent.ts | 14 +- src/__tests__/accessibilityProps.test.ts | 28 + src/__tests__/measureBox.test.ts | 21 + src/__tests__/numberFormat.test.ts | 241 +++++++++ src/accessibilityProps.ts | 20 + src/index.tsx | 2 +- src/measureBox.ts | 61 ++- src/numberFormat.ts | 255 +++++++++ src/types.ts | 109 +++- 35 files changed, 3435 insertions(+), 468 deletions(-) create mode 100644 android/src/main/java/com/numerictext/NumericTextFormatter.kt create mode 100644 android/src/test/java/com/numerictext/FormatGeometryPinTest.kt create mode 100644 android/src/test/java/com/numerictext/SemanticTokenizationTest.kt create mode 100644 android/src/test/java/com/numerictext/UsdCodeRepeatTransitionTest.kt create mode 100644 example/src/FormatLab.tsx create mode 100644 src/__tests__/accessibilityProps.test.ts create mode 100644 src/__tests__/numberFormat.test.ts create mode 100644 src/accessibilityProps.ts create mode 100644 src/numberFormat.ts 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 1b36326a1a041553960b3a223a4fce23d8f0affd..424db248682ccd11c903c588ef55ca5d02866f88 100644 GIT binary patch literal 33840 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 RW2<`nUBPD%l$S(%@fR%C9%}#q delta 1050 zcmYk5YiJx*6vzK}c6VM`xBH0pwQVMw?KVm6W{oALV08C0c5g(0P>4#d2Z5kC9~_xEktiXLJ-6qRAIbBdj?o>ClCrw z*?TG_3d3A5&bK}_J-ciCtEcu+Om3XWPudeb508He+`?&ekCMj<_TG}>58pwqlC8(=!elCQR~OLA0L0l+aqXV+^_^lf z(DtV*C_OP*+MR2e15)mZe{>doN1RsU(861hq*$n}JEM!0&S-j#h&prWHIY~!q!hif z?#n9*e26;7(+kesnYU||oa%hD`*I~vcNM3Az^gi_Pz3{(5+4HUFM1pbv>}(@l$W(; zkM*?6Gk`Wg4+Vy&KhOEF$7ZWLe~{ef=^HGUuW`K#-VQn;Koo{J$`pmfPj3pU7cj5=j(K$s z+ng(fp}21V2b3=4wZpi^@k2urV-1IKby*!(op5nPY;>lJ?W?M88Gw5@*mpqh1H)OI zg(XAIiDG93CuvK;;*nH7*I_-6y%}3@KWrSuY0I!;)=Ddrh$hy`pbRll>>E$F0h#~l zG*8#@pH5zqOY&FN<}>-ed`H%#EqB!QIy(edG%reF2pxu-XZNz4+p00|x4OZ=YQ(Ud zwTPf;A@lejk%*6F@G~zOZ-ExHIOS(&dz+EqcajmM7<(()(2k9`m0E>`3URe47c%ML R=2WNEX+mAOB`!QPn_&|#Qemq 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 delta 1049 zcmY+CZ)_7~9LK-UwRgSt>an&&*k6xs&M~%i6hhP>EDYSD3tOy&$dv5XjSZx%ZiRp` zX*CPH5f~mCH4CZH7y^qgEMQ`g7wQXMOhbq!7$YfT*_LPmL|(uWc>G>55%2Omcc0(y z`~3O+$)8a2y;%qVAx>VDb!^`9B3?ioX!?=XXh%9cRoJPS1I zq;(*Y5|GPtd<^n1<>*p&dLhN3I)A zWe%=cQgNPs9)JsbN0RoZUwz2^mba33rtE`h#aq@wrVZ9UJ2lu)xv>ps5-bT z%)_mM(KJoEg7oNMdUvw+ZJ^;A`IfWjnRHD5@xpv)SQP%OKdFmyCl{+0Vdqq=Iuz-G zlw!M_^R$Xf@F46=#|ln%_1A@w!Hn*-@0-c0i>~AhpvHNM2bze278C(3uUA2VuhZpA za#p)iVm<0|j{bXGp@7koXX*dG#DNx<9rD$Z-dO7LU(_Ef`GfDc`~m&H=v7&6@I?6k z3ry4HoSf4Z^cwmt_lns0U`lDj6>{XE%V*`BGR~+JgqaC82wHeZ0D1Um>%jz$(l%oT zp|W=#Jul;==oFub-;|BYRZoNGv^P=b?eos7$$#IY>ZE#BEvOgOc`dB9Yj0`=?YeJN zUkH3@gp6_{YBU;g<9TC~@sjb1vBT&$_86lE%Tc<3=&t3+@{OWiREyl;df*Q!{A@$Nn`ig2oD<`bWlJAHX|6;B z%UFvLo+?c2_k}|qmch%s`1lIcqSl!`a;SX;qWrJpjp}*#2CPIQn$bk9!a|wcD$I=n TT;b+ar!~M~VHL?$tN{K2`ep`{ diff --git a/android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf b/android/src/main/assets/fonts/SunghyunSans-ExtraBold.ttf index 5cf36567ea3a4d79d74ce06b8a4e189c66950e57..b78b887b6053a2326faee6a18c240f33a07c0cc6 100644 GIT binary patch literal 35388 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 IQ>1`@00$!ebN~PV diff --git a/android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf b/android/src/main/assets/fonts/SunghyunSans-ExtraLight.ttf index 26bb0e478c4fcfc5f5d59fa0e66038564bfef816..a2a7015d96164837aab86eb45348804cf5249f13 100644 GIT binary patch literal 33148 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 diff --git a/android/src/main/assets/fonts/SunghyunSans-Light.ttf b/android/src/main/assets/fonts/SunghyunSans-Light.ttf index 62c38572ca7331d9ddcc1d9de4b9c888df39b457..6010a5e47b681f574d2f80c2395ca75f5b8ca365 100644 GIT binary patch literal 34840 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 OFlzC!(N6Lejle%?nFT)p diff --git a/android/src/main/assets/fonts/SunghyunSans-Regular.ttf b/android/src/main/assets/fonts/SunghyunSans-Regular.ttf index 4c7099063bb91c74f110da49c1f87248c5e97e8a..189e28e21969d1a5368d782c4b750c5f85039851 100644 GIT binary patch literal 34096 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 XP2LHSp;U{h>XBUGw+hkMPI~nZc{%iF delta 1078 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 TB)P)&)L>RJOvFjHq8|7Mqa6zj diff --git a/android/src/main/assets/fonts/SunghyunSans-Thin.ttf b/android/src/main/assets/fonts/SunghyunSans-Thin.ttf index f2bb8b27aaafc869deea8c87db75d2a53757c216..fc69e62de582afae1202ffb1cb1fff8e0b095db2 100644 GIT binary patch literal 32836 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 Kf-@tKtv>-SW!@P8 delta 1116 zcmY+CUrZcT6vn?hJ9l?xSvKr$p|%A(uq;9WL6SBVp>1f9##q5@iA~h%^1sb)SuJiT zCS9<7@DD`7w8Rh}YFg8VY&24BO;Z!K#MDI57&WA(W;OUij2eqtAL#hqw{=U15i0kYgt=Idvs6g;pfP&ldZOBXUiK`X19~C zlBe6Fbqy!t%ijToaq`pMiCD7d+Q}ICGxGKB)R1-l=Dt^f$Rr^2zGQ!*tIjh96rBS6 zzW&(JbdtgX22@|8KYpyQ@xIwXikpDo%s_9fXXi{;7+80J>)Qrs*jy9%gd6N7mk%U{ zh6_?wIoG)XF2x7CV<$uD9#(Xld@vCkPHK9AhrCF(j>HnZk!#KG02NF?TuBb@I68DE zg8fXOLkW`4_9hQ?hesIu4Efg-H`K@cA)xAAa(RjyjPj*N zxUL(V;Knb?2^m0UjACf;EGWM0EXhkbHojHN0;KZLU@j~Eoa?XU*sCd?Bd_`AttkFF z7fP%)89y6;7^|i<^UXrD#Eh7==2o-WY&G{Wx=KK_ zZ}Ofle8tZ6Z_V2#ii}24BtOQq{r*5lv6w@b=EI_UK@@RpG+)5DF@pKLQ<%52haMNT zcGKZ48%NbC0J}JNF@fFtJEQmzmgBca4>zQtE? = 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;