@octane-xplat/ui 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/dist/native/Image.tsrx.js +151 -16
  2. package/dist/native/hash-image.js +285 -0
  3. package/dist/native/image-content-binding.js +158 -0
  4. package/dist/native/image-content.js +206 -0
  5. package/dist/native/image-sizing.js +102 -0
  6. package/dist/native/octane-client-build.json +1 -1
  7. package/dist/native/vendor/ui-svg/index.android.js +1 -1
  8. package/dist/web/Image.web.tsrx.js +112 -19
  9. package/dist/web/hash-image.js +285 -0
  10. package/dist/web/image-content.js +133 -0
  11. package/dist/web/octane-client-build.json +1 -1
  12. package/package.json +5 -4
  13. package/src/Image.macos.tsrx +11 -2
  14. package/src/Image.tsrx +203 -17
  15. package/src/Image.web.tsrx +91 -5
  16. package/src/Tabs.macos.tsrx +2 -0
  17. package/src/Timestamp.macos.tsrx +1 -1
  18. package/src/colorScheme.macos.ts +18 -5
  19. package/src/hash-image.test.ts +72 -0
  20. package/src/hash-image.ts +310 -0
  21. package/src/hosted-unsupported.macos.tsrx +1 -2
  22. package/src/image-content-binding.mobile.test.ts +174 -0
  23. package/src/image-content-binding.ts +231 -0
  24. package/src/image-content.mobile.test.tsrx +84 -0
  25. package/src/image-content.test.ts +219 -0
  26. package/src/image-content.ts +309 -0
  27. package/src/image-decoding.web.test.tsrx +26 -0
  28. package/src/image-placeholder.mobile.test.tsrx +113 -0
  29. package/src/image-placeholder.web.test.tsrx +48 -0
  30. package/src/image-recycling.mobile.test.ts +61 -0
  31. package/src/image-sizing.mobile.test.ts +158 -0
  32. package/src/image-sizing.ts +117 -0
  33. package/src/image.mobile.test.tsrx +81 -0
  34. package/src/index.macos.ts +1 -0
  35. package/src/index.shared.ts +7 -0
  36. package/src/index.shared.web.ts +7 -0
  37. package/src/list-viewability-observer.ios.ts +1 -0
  38. package/src/list-viewability.mobile.test.ts +1 -0
  39. package/src/props.ts +100 -1
  40. package/src/safeAreaInsets.macos.tsrx +6 -1
  41. package/src/styled.macos.tsrx +1 -1
  42. package/src/text-controls.macos.tsrx +3 -1
  43. package/src/vendor/ui-svg/src/index.android.ts +5 -1
  44. package/types/generated/.tsrx-typegen-manifest.json +170 -0
  45. package/types/generated/Accordion.macos.d.ts +5 -0
  46. package/types/generated/Alert.macos.d.ts +3 -0
  47. package/types/generated/AlertDialog.macos.d.ts +4 -0
  48. package/types/generated/AppShell.macos.d.ts +7 -0
  49. package/types/generated/AspectRatio.macos.d.ts +5 -0
  50. package/types/generated/Avatar.macos.d.ts +6 -0
  51. package/types/generated/AvatarGroup.macos.d.ts +6 -0
  52. package/types/generated/Badge.macos.d.ts +5 -0
  53. package/types/generated/Banner.macos.d.ts +4 -0
  54. package/types/generated/BaseTypeahead.macos.d.ts +7 -0
  55. package/types/generated/Blockquote.macos.d.ts +6 -0
  56. package/types/generated/BottomSheet.macos.d.ts +3 -0
  57. package/types/generated/Breadcrumb.macos.d.ts +4 -0
  58. package/types/generated/ButtonGroup.macos.d.ts +3 -0
  59. package/types/generated/Calendar.macos.d.ts +5 -0
  60. package/types/generated/Card.macos.d.ts +4 -0
  61. package/types/generated/Carousel.macos.d.ts +6 -0
  62. package/types/generated/Center.macos.d.ts +4 -0
  63. package/types/generated/CheckIndicator.macos.d.ts +4 -0
  64. package/types/generated/CheckboxGroup.macos.d.ts +3 -0
  65. package/types/generated/CheckboxIndicator.macos.d.ts +4 -0
  66. package/types/generated/Chip.macos.d.ts +5 -0
  67. package/types/generated/Citation.macos.d.ts +4 -0
  68. package/types/generated/ClickableCard.macos.d.ts +3 -0
  69. package/types/generated/Code.macos.d.ts +5 -0
  70. package/types/generated/CodeBlock.macos.d.ts +6 -0
  71. package/types/generated/Collapsible.macos.d.ts +5 -0
  72. package/types/generated/CommandPalette.macos.d.ts +3 -0
  73. package/types/generated/ComplexSelector.macos.d.ts +3 -0
  74. package/types/generated/ContextMenu.macos.d.ts +5 -0
  75. package/types/generated/DateInput.macos.d.ts +6 -0
  76. package/types/generated/DateRangeInput.macos.d.ts +5 -0
  77. package/types/generated/DateTimeInput.macos.d.ts +5 -0
  78. package/types/generated/Dialog.macos.d.ts +3 -0
  79. package/types/generated/Drawer.macos.d.ts +7 -0
  80. package/types/generated/DropdownMenu.macos.d.ts +5 -0
  81. package/types/generated/Empty.macos.d.ts +4 -0
  82. package/types/generated/FieldGroup.macos.d.ts +4 -0
  83. package/types/generated/FormField.macos.d.ts +4 -0
  84. package/types/generated/FormLayout.macos.d.ts +6 -0
  85. package/types/generated/HStack.macos.d.ts +4 -0
  86. package/types/generated/Heading.macos.d.ts +3 -0
  87. package/types/generated/Icon.macos.d.ts +3 -0
  88. package/types/generated/IconButton.macos.d.ts +3 -0
  89. package/types/generated/Image.d.ts +0 -5
  90. package/types/generated/Image.macos.d.ts +2 -0
  91. package/types/generated/Image.web.d.ts +12 -1
  92. package/types/generated/InputGroup.macos.d.ts +5 -0
  93. package/types/generated/InputNumber.macos.d.ts +5 -0
  94. package/types/generated/InputRating.macos.d.ts +4 -0
  95. package/types/generated/Item.macos.d.ts +3 -0
  96. package/types/generated/Kbd.macos.d.ts +4 -0
  97. package/types/generated/KeyboardAvoiding.macos.d.ts +3 -0
  98. package/types/generated/Lightbox.macos.d.ts +2 -0
  99. package/types/generated/Link.macos.d.ts +3 -0
  100. package/types/generated/List.macos.d.ts +4 -0
  101. package/types/generated/ListItem.macos.d.ts +5 -0
  102. package/types/generated/MenuParts.macos.d.ts +9 -0
  103. package/types/generated/MenuSurface.macos.d.ts +4 -0
  104. package/types/generated/MetadataList.macos.d.ts +4 -0
  105. package/types/generated/MetadataListItem.macos.d.ts +3 -0
  106. package/types/generated/Meter.macos.d.ts +3 -0
  107. package/types/generated/MobileNav.macos.d.ts +15 -0
  108. package/types/generated/MoreMenu.macos.d.ts +2 -0
  109. package/types/generated/NavIcon.macos.d.ts +4 -0
  110. package/types/generated/NavLink.macos.d.ts +3 -0
  111. package/types/generated/NavMenu.macos.d.ts +11 -0
  112. package/types/generated/NavigationMenu.macos.d.ts +4 -0
  113. package/types/generated/Outline.macos.d.ts +5 -0
  114. package/types/generated/OverflowList.macos.d.ts +6 -0
  115. package/types/generated/Overlay.macos.d.ts +3 -0
  116. package/types/generated/Pagination.macos.d.ts +4 -0
  117. package/types/generated/PinInput.macos.d.ts +4 -0
  118. package/types/generated/Popover.macos.d.ts +3 -0
  119. package/types/generated/PowerSearch.macos.d.ts +7 -0
  120. package/types/generated/PowerSearchFilterEditor.macos.d.ts +12 -0
  121. package/types/generated/PowerSearchToken.macos.d.ts +7 -0
  122. package/types/generated/Pressable.macos.d.ts +2 -0
  123. package/types/generated/ProgressBar.macos.d.ts +6 -0
  124. package/types/generated/ProgressGroup.macos.d.ts +3 -0
  125. package/types/generated/RadioGroup.macos.d.ts +5 -0
  126. package/types/generated/RadioIndicator.macos.d.ts +3 -0
  127. package/types/generated/ResizeHandle.macos.d.ts +7 -0
  128. package/types/generated/RouteBoundary.macos.d.ts +8 -0
  129. package/types/generated/RouteHost.macos.d.ts +13 -0
  130. package/types/generated/Row.macos.d.ts +2 -0
  131. package/types/generated/SafeArea.macos.d.ts +3 -0
  132. package/types/generated/Screen.macos.d.ts +3 -0
  133. package/types/generated/ScrollableArea.macos.d.ts +7 -0
  134. package/types/generated/Section.macos.d.ts +5 -0
  135. package/types/generated/Select.macos.d.ts +3 -0
  136. package/types/generated/SelectableCard.macos.d.ts +3 -0
  137. package/types/generated/Separator.macos.d.ts +5 -0
  138. package/types/generated/SideNav.macos.d.ts +19 -0
  139. package/types/generated/Skeleton.macos.d.ts +5 -0
  140. package/types/generated/StatusDot.macos.d.ts +5 -0
  141. package/types/generated/Stepper.macos.d.ts +4 -0
  142. package/types/generated/TabList.macos.d.ts +18 -0
  143. package/types/generated/Table.macos.d.ts +4 -0
  144. package/types/generated/Tabs.macos.d.ts +3 -0
  145. package/types/generated/Text.macos.d.ts +5 -0
  146. package/types/generated/Thumbnail.macos.d.ts +7 -0
  147. package/types/generated/TimeInput.macos.d.ts +6 -0
  148. package/types/generated/TimeWheelPanel.macos.d.ts +16 -0
  149. package/types/generated/Timeline.macos.d.ts +3 -0
  150. package/types/generated/Timer.macos.d.ts +3 -0
  151. package/types/generated/Timestamp.macos.d.ts +5 -0
  152. package/types/generated/Toast.macos.d.ts +3 -0
  153. package/types/generated/ToggleButton.macos.d.ts +3 -0
  154. package/types/generated/Token.macos.d.ts +5 -0
  155. package/types/generated/Tokenizer.macos.d.ts +6 -0
  156. package/types/generated/Toolbar.macos.d.ts +6 -0
  157. package/types/generated/TopNav.macos.d.ts +25 -0
  158. package/types/generated/Tree.macos.d.ts +4 -0
  159. package/types/generated/Typeahead.macos.d.ts +8 -0
  160. package/types/generated/TypeaheadItem.macos.d.ts +5 -0
  161. package/types/generated/User.macos.d.ts +5 -0
  162. package/types/generated/VStack.macos.d.ts +4 -0
  163. package/types/generated/View.macos.d.ts +3 -0
  164. package/types/generated/VirtualList.macos.d.ts +6 -0
  165. package/types/generated/VisuallyHidden.macos.d.ts +5 -0
  166. package/types/generated/WebView.macos.d.ts +2 -0
  167. package/types/generated/aliases.macos.d.ts +3 -0
  168. package/types/generated/anim.macos.d.ts +3 -0
  169. package/types/generated/animation-host.macos.d.ts +3 -0
  170. package/types/generated/bottom-sheet-panel.macos.d.ts +7 -0
  171. package/types/generated/bottom-sheet-service.macos.d.ts +5 -0
  172. package/types/generated/clipboard-write.macos.d.ts +3 -0
  173. package/types/generated/colorScheme.macos.d.ts +4 -0
  174. package/types/generated/command-palette-keys.macos.d.ts +2 -0
  175. package/types/generated/command-palette-scroll.macos.d.ts +2 -0
  176. package/types/generated/controls.macos.d.ts +8 -0
  177. package/types/generated/date-composition.macos.d.ts +5 -0
  178. package/types/generated/escape-props.macos.d.ts +5 -0
  179. package/types/generated/hash-image.d.ts +13 -0
  180. package/types/generated/hosted-unsupported.macos.d.ts +1 -0
  181. package/types/generated/image-content-binding.d.ts +64 -0
  182. package/types/generated/image-content.d.ts +56 -0
  183. package/types/generated/image-sizing.d.ts +37 -0
  184. package/types/generated/index.macos.d.ts +196 -0
  185. package/types/generated/index.shared.d.ts +1 -1
  186. package/types/generated/index.shared.web.d.ts +1 -1
  187. package/types/generated/layout-shells.macos.d.ts +3 -0
  188. package/types/generated/layout.macos.d.ts +9 -0
  189. package/types/generated/macos-extras.macos.d.ts +4 -0
  190. package/types/generated/measure-bounds.macos.d.ts +3 -0
  191. package/types/generated/nav-href.macos.d.ts +4 -0
  192. package/types/generated/outline-dom.macos.d.ts +2 -0
  193. package/types/generated/pan.macos.d.ts +3 -0
  194. package/types/generated/platform.macos.d.ts +1 -0
  195. package/types/generated/presentation.macos.d.ts +15 -0
  196. package/types/generated/props.d.ts +76 -1
  197. package/types/generated/query-storage.macos.d.ts +8 -0
  198. package/types/generated/resize-basis.macos.d.ts +4 -0
  199. package/types/generated/resize-persist.macos.d.ts +8 -0
  200. package/types/generated/route.macos.d.ts +28 -0
  201. package/types/generated/safeAreaInsets.macos.d.ts +10 -0
  202. package/types/generated/stacks.macos.d.ts +3 -0
  203. package/types/generated/styled.macos.d.ts +9 -0
  204. package/types/generated/surfaces.macos.d.ts +4 -0
  205. package/types/generated/svg-image.macos.d.ts +3 -0
  206. package/types/generated/text-controls.macos.d.ts +4 -0
  207. package/types/generated/theme/theme-scheme.macos.d.ts +8 -0
  208. package/types/generated/toast-service.macos.d.ts +3 -0
  209. package/types/generated/toast-viewport.macos.d.ts +5 -0
  210. package/types/generated/translate.macos.d.ts +1 -0
  211. package/types/generated/unsupported.macos.d.ts +1 -0
  212. package/types/generated/use-imperative-alert-dialog.macos.d.ts +2 -0
  213. package/types/generated/use-imperative-dialog.macos.d.ts +2 -0
  214. package/types/generated/use-lightbox.macos.d.ts +2 -0
  215. package/types/generated/use-scrollable-area.macos.d.ts +6 -0
  216. package/types/generated/useMeasure.macos.d.ts +3 -0
  217. package/types/generated/useResizable.macos.d.ts +8 -0
  218. package/types/generated/useStore.macos.d.ts +2 -0
  219. package/types/generated/windows.macos.d.ts +2 -0
  220. package/src/CameraView.macos.tsrx +0 -6
  221. package/types/index.macos.d.ts +0 -448
@@ -0,0 +1,231 @@
1
+ import { bestImageSource, contentRect } from './image-content';
2
+ import type {
3
+ ImageContentFit,
4
+ ImageContentPositionObject,
5
+ ImageSourceLike,
6
+ } from './props';
7
+
8
+ export interface ImageContentEnv {
9
+ /** Platform gate for the intrinsic-size unit conversion — the leaf
10
+ * supplies it so this module stays free of '@nativescript/core'. */
11
+ isAndroid: boolean;
12
+ /** Device pixel ratio for the L8 pixel-count target. */
13
+ screenScale: () => number;
14
+ /** Src write seam — the sized-decode binding (image-sizing.ts) owns WHEN
15
+ * a source lands on the view: first-layout gate, decode dims, resize
16
+ * reload. `stretchForDecode` overrides the view's stretch for those
17
+ * gates on the computed path, whose inner image draws stretch='fill'
18
+ * under a different semantic fit ('none' needs the full-resolution
19
+ * source to find the natural size). */
20
+ issue: (view: any, source: any, stretchForDecode?: string) => void;
21
+ }
22
+
23
+ export interface ImageContentUpdate {
24
+ /** The resolved single source (string or ImageSource), null when
25
+ * `sources` carries the request. */
26
+ source: any;
27
+ /** Resolution-qualified sources (L8) — the binding defers selection
28
+ * until a real layout exists, picks the closest pixel count, then
29
+ * hands the winner to `issue`. */
30
+ sources: readonly ImageSourceLike[] | null;
31
+ /** Resolved CSS fit/position for the computed path. */
32
+ fit: ImageContentFit;
33
+ position: ImageContentPositionObject;
34
+ /** True when the image view lives in a clip host and is laid out by the
35
+ * binding (width/height/left/top in dips) — needed for 'none',
36
+ * 'scale-down', and non-center positions no platform scaleType has. */
37
+ computed: boolean;
38
+ }
39
+
40
+ /** Intrinsic size in dips. iOS `UIImage.size` is already points; Android
41
+ * `Bitmap` reports raw pixels — density-bucketed resource bitmaps convert
42
+ * through their bucket (a 300px xxxhdpi asset = 75 dips), while decoded
43
+ * remote/file bitmaps carry no density and treat px as dips, which is the
44
+ * CSS 'none' natural size on every platform. */
45
+ function intrinsicDipSize(
46
+ imageSource: any,
47
+ android: boolean,
48
+ ): { w: number; h: number } | null {
49
+ const width = imageSource?.width;
50
+ const height = imageSource?.height;
51
+ if (!width || !height) {
52
+ return null;
53
+ }
54
+
55
+ if (android) {
56
+ const density = imageSource.android?.getDensity?.() ?? 0;
57
+ if (density > 0) {
58
+ return { w: (width * 160) / density, h: (height * 160) / density };
59
+ }
60
+ }
61
+
62
+ return { w: width, h: height };
63
+ }
64
+
65
+ /** Measure → compute → apply for Image's content contract (study L7).
66
+ * NS's stock widget owns its draw path — the widgets ImageView computes
67
+ * its own matrix in onDraw and honors only the four stretch scaleTypes —
68
+ * so the drawable-level matrix expo-image uses is engine internals. What
69
+ * the leaf can own is the view-level equivalent: measure the box
70
+ * (`layoutChanged` → `getActualSize`, dips), compute the content rect
71
+ * with the same math as object-fit/object-position, and lay out the
72
+ * inner <image stretch="fill"> to that rect inside a clipped host. One
73
+ * code path produces identical rects on iOS and Android.
74
+ *
75
+ * This binding never writes `src` itself — it resolves WHICH source the
76
+ * view should load and hands it to `env.issue`, the sized-decode binding
77
+ * (study L1). That seam composes both contracts: the L8 array pick waits
78
+ * on the same first real layout the decode gate needs, and the computed
79
+ * path passes the semantic fit as the decode gate so 'none' still
80
+ * decodes at source resolution while 'fill' would have been
81
+ * view-sampled.
82
+ *
83
+ * Before the bitmap's intrinsic size is known the computed path sizes
84
+ * the inner image to the host box: a real rect is what lets the decode
85
+ * gate's first-layout check pass, and the placeholder is the CSS 'fill'
86
+ * answer until imageSourceChange refines it.
87
+ *
88
+ * `update` re-runs on every render (ref identity churn); all writes are
89
+ * idempotent. Null refs on detach are ignored — a dead listener is a
90
+ * self-contained cycle GC collects with the view. */
91
+ export function createImageContentBinding(env: ImageContentEnv) {
92
+ let sizeView: any = null;
93
+ let imageView: any = null;
94
+ let update: ImageContentUpdate = {
95
+ source: null,
96
+ sources: null,
97
+ fit: 'cover',
98
+ position: {},
99
+ computed: false,
100
+ };
101
+
102
+ let appliedW = 0;
103
+ let appliedH = 0;
104
+ let appliedX = 0;
105
+ let appliedY = 0;
106
+ let listening = false;
107
+ let sourceListening = false;
108
+
109
+ const screenScale = () => {
110
+ try {
111
+ return env.screenScale();
112
+ } catch {
113
+ return 1;
114
+ }
115
+ };
116
+
117
+ // 'none' decodes at source resolution — its contract is natural size and
118
+ // the rect math needs the true intrinsic. Everything else decodes at
119
+ // view size: for 'scale-down' the intrinsic-vs-view comparison survives
120
+ // downsampling (a bigger source still reads >= view), though a source
121
+ // barely larger than the view can sample to just under it and resolve
122
+ // as 'none' — a small undersize vs CSS, accepted for one decode pass.
123
+ const decodeGate = () => (update.fit === 'none' ? 'none' : 'aspectFit');
124
+
125
+ const evaluate = () => {
126
+ if (!sizeView) {
127
+ return;
128
+ }
129
+
130
+ const needsViewSize = update.computed || update.sources != null;
131
+ let viewWidth = 0;
132
+ let viewHeight = 0;
133
+ if (needsViewSize) {
134
+ const actual = sizeView.getActualSize?.();
135
+ viewWidth = actual?.width ?? 0;
136
+ viewHeight = actual?.height ?? 0;
137
+ if (viewWidth <= 0 || viewHeight <= 0) {
138
+ return;
139
+ }
140
+ }
141
+
142
+ if (imageView) {
143
+ let resolved: any = update.source;
144
+ if (update.sources?.length) {
145
+ resolved = bestImageSource(update.sources, viewWidth, viewHeight, screenScale())?.uri;
146
+ }
147
+
148
+ if (resolved != null && resolved !== '') {
149
+ env.issue(imageView, resolved, update.computed ? decodeGate() : undefined);
150
+ }
151
+ }
152
+
153
+ if (!update.computed || !imageView) {
154
+ return;
155
+ }
156
+
157
+ const intrinsic = intrinsicDipSize(imageView.imageSource, env.isAndroid);
158
+ const rect = intrinsic
159
+ ? contentRect(update.fit, update.position, viewWidth, viewHeight, intrinsic.w, intrinsic.h)
160
+ : { x: 0, y: 0, width: viewWidth, height: viewHeight };
161
+
162
+ if (
163
+ rect.width !== appliedW ||
164
+ rect.height !== appliedH ||
165
+ rect.x !== appliedX ||
166
+ rect.y !== appliedY
167
+ ) {
168
+ appliedW = rect.width;
169
+ appliedH = rect.height;
170
+ appliedX = rect.x;
171
+ appliedY = rect.y;
172
+ imageView.width = rect.width;
173
+ imageView.height = rect.height;
174
+ imageView.left = rect.x;
175
+ imageView.top = rect.y;
176
+ }
177
+ };
178
+
179
+ const onLayout = () => evaluate();
180
+ const onImageSource = () => evaluate();
181
+
182
+ const listen = () => {
183
+ const wantsMeasure = update.computed || update.sources != null;
184
+ if (wantsMeasure && !listening && typeof sizeView?.on === 'function') {
185
+ sizeView.on('layoutChanged', onLayout);
186
+ listening = true;
187
+ } else if (!wantsMeasure && listening) {
188
+ sizeView?.off?.('layoutChanged', onLayout);
189
+ listening = false;
190
+ }
191
+
192
+ if (update.computed && !sourceListening && typeof imageView?.on === 'function') {
193
+ imageView.on('imageSourceChange', onImageSource);
194
+ sourceListening = true;
195
+ } else if (!update.computed && sourceListening) {
196
+ imageView?.off?.('imageSourceChange', onImageSource);
197
+ sourceListening = false;
198
+ }
199
+ };
200
+
201
+ return {
202
+ /** (Re)bind views + intent. `sizeView` is the measured box (the image
203
+ * itself on the stretch path, the clip host on the computed path);
204
+ * `imageView` is the <image> that draws. */
205
+ update(nextSizeView: any, nextImageView: any, next: ImageContentUpdate): void {
206
+ if (nextSizeView && nextSizeView !== sizeView) {
207
+ sizeView?.off?.('layoutChanged', onLayout);
208
+ sizeView = nextSizeView;
209
+ listening = false;
210
+ }
211
+
212
+ if (nextImageView && nextImageView !== imageView) {
213
+ imageView?.off?.('imageSourceChange', onImageSource);
214
+ imageView = nextImageView;
215
+ sourceListening = false;
216
+ }
217
+
218
+ update = next;
219
+ listen();
220
+ evaluate();
221
+ },
222
+ dispose(): void {
223
+ sizeView?.off?.('layoutChanged', onLayout);
224
+ imageView?.off?.('imageSourceChange', onImageSource);
225
+ sizeView = null;
226
+ imageView = null;
227
+ listening = false;
228
+ sourceListening = false;
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,84 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { createObjectContainer, createObjectDriver, createUniversalRoot } from 'octane/universal/native'
4
+
5
+ vi.mock('@nativescript/core', () => ({
6
+ isIOS: false,
7
+ isAndroid: true,
8
+ Screen: { mainScreen: { scale: 3 } },
9
+ ScrollView: class {},
10
+ ImageSource: class {
11
+ static fromBase64Sync() {
12
+ return { mocked: true }
13
+ }
14
+ },
15
+ }))
16
+
17
+ // The raster path is under test; ./svg resolves to svg.mobile.ts, which
18
+ // registers SVGView from the vendored submodule (needs real NS core).
19
+ vi.mock('./svg', () => ({
20
+ isSvgSrc: () => false,
21
+ svgSource: (s: string) => s,
22
+ }))
23
+
24
+ import { Image } from './Image.tsrx'
25
+
26
+ function render(props: any) {
27
+ const container = createObjectContainer('nativescript')
28
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
29
+ root.render(Image as any, props)
30
+ return { container, root, host: container.children[0] as any }
31
+ }
32
+
33
+ describe('native Image — contentFit fast path (L7)', () => {
34
+ it('defaults to cover → aspectFill (the web/img fill and NS aspectFit defaults diverged)', () => {
35
+ const { host } = render({ src: 'https://img.example/x.png' })
36
+ expect(host.type).toBe('image')
37
+ expect(host.props.stretch).toBe('aspectFill')
38
+ })
39
+
40
+ it('maps contain/cover/fill onto stretch', () => {
41
+ expect(render({ src: 'x', contentFit: 'contain' }).host.props.stretch).toBe('aspectFit')
42
+ expect(render({ src: 'x', contentFit: 'cover' }).host.props.stretch).toBe('aspectFill')
43
+ expect(render({ src: 'x', contentFit: 'fill' }).host.props.stretch).toBe('fill')
44
+ })
45
+
46
+ it('routes none/scale-down/positioned content through the computed wrapper', () => {
47
+ const none = render({ src: 'x', contentFit: 'none' }).host
48
+ expect(none.type).toBe('absolutelayout')
49
+ const inner = none.children[0] as any
50
+ expect(inner.type).toBe('image')
51
+ expect(inner.props.stretch).toBe('fill')
52
+
53
+ const scaleDown = render({ src: 'x', contentFit: 'scale-down' }).host
54
+ expect(scaleDown.type).toBe('absolutelayout')
55
+ const positioned = render({ src: 'x', contentFit: 'cover', contentPosition: 'top left' }).host
56
+ expect(positioned.type).toBe('absolutelayout')
57
+ // fill ignores position — the rect is always the whole box
58
+ const filled = render({ src: 'x', contentFit: 'fill', contentPosition: 'top left' }).host
59
+ expect(filled.type).toBe('image')
60
+ })
61
+ })
62
+
63
+ describe('native Image — multi-source selection (L8)', () => {
64
+ const sources = [
65
+ { uri: 'https://img.example/p-200.jpg', width: 200, height: 200 },
66
+ { uri: 'https://img.example/p-800.jpg', width: 800, height: 800 },
67
+ ]
68
+
69
+ it('defers src until a real layout, then assigns the closest source', () => {
70
+ const { root, host } = render({ src: sources })
71
+ expect(host.type).toBe('image')
72
+ expect(host.src).toBeUndefined()
73
+
74
+ // re-render re-fires the ref → binding.update → content binding picks
75
+ // (200x200 dips at scale 3 → 600² px → 800² wins) and hands it to the
76
+ // sized binding, whose measured-width gate lands the src + decode dims
77
+ host.getActualSize = () => ({ width: 200, height: 200 })
78
+ host.getMeasuredWidth = () => 600
79
+ host.getMeasuredHeight = () => 600
80
+ root.render(Image as any, { src: sources })
81
+ expect(host.src).toBe('https://img.example/p-800.jpg')
82
+ expect(host.decodeWidth).toEqual({ value: 600, unit: 'px' })
83
+ })
84
+ })
@@ -0,0 +1,219 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ bestImageSource,
4
+ contentAxisOffset,
5
+ contentFitToStretch,
6
+ contentRect,
7
+ imageSrcSet,
8
+ isCenteredContentPosition,
9
+ needsContentRect,
10
+ objectPositionCSSValue,
11
+ resolveContentFit,
12
+ resolveContentPosition,
13
+ } from './image-content'
14
+
15
+ const CENTER = { top: '50%', left: '50%' } as const
16
+
17
+ describe('resolveContentFit / resolveContentPosition', () => {
18
+ it('defaults to cover and 50%/50%', () => {
19
+ expect(resolveContentFit(undefined)).toBe('cover')
20
+ expect(resolveContentPosition(undefined)).toEqual(CENTER)
21
+ })
22
+
23
+ it('maps keyword shorthands like expo-image', () => {
24
+ expect(resolveContentPosition('top right')).toEqual({ top: 0, right: 0 })
25
+ expect(resolveContentPosition('bottom')).toEqual({ bottom: 0, left: '50%' })
26
+ expect(resolveContentPosition('left center')).toEqual({ top: '50%', left: 0 })
27
+ })
28
+
29
+ it('falls back to center for invalid keywords', () => {
30
+ expect(resolveContentPosition('diagonal' as any)).toEqual(CENTER)
31
+ })
32
+
33
+ it('detects centered positions including explicit 50% edges', () => {
34
+ expect(isCenteredContentPosition({})).toBe(true)
35
+ expect(isCenteredContentPosition({ left: '50%' })).toBe(true)
36
+ expect(isCenteredContentPosition({ right: '50%' })).toBe(true)
37
+ expect(isCenteredContentPosition({ left: 'center' })).toBe(true)
38
+ expect(isCenteredContentPosition({ top: 0 })).toBe(false)
39
+ expect(isCenteredContentPosition({ left: 10 })).toBe(false)
40
+ expect(isCenteredContentPosition({ right: '25%' })).toBe(false)
41
+ })
42
+ })
43
+
44
+ describe('contentAxisOffset', () => {
45
+ const img = 200
46
+ const view = 400
47
+
48
+ it('centers by default and honors percentages over the free space', () => {
49
+ expect(contentAxisOffset(view, img, undefined, undefined)).toBe(100)
50
+ expect(contentAxisOffset(view, img, '0%', undefined)).toBe(0)
51
+ expect(contentAxisOffset(view, img, '25%', undefined)).toBe(50)
52
+ expect(contentAxisOffset(view, img, 'center', undefined)).toBe(100)
53
+ })
54
+
55
+ it('measures absolute dips from the named edge', () => {
56
+ expect(contentAxisOffset(view, img, 10, undefined)).toBe(10)
57
+ expect(contentAxisOffset(view, img, undefined, 10)).toBe(view - img - 10)
58
+ expect(contentAxisOffset(view, img, undefined, '75%')).toBe(50)
59
+ })
60
+ })
61
+
62
+ describe('contentRect', () => {
63
+ // 200x100 image inside a 400x300 box
64
+ it('contain letterboxes centered', () => {
65
+ expect(contentRect('contain', CENTER, 400, 300, 200, 100)).toEqual({
66
+ x: 0,
67
+ y: 50,
68
+ width: 400,
69
+ height: 200,
70
+ })
71
+ })
72
+
73
+ it('cover overflows evenly with negative offset', () => {
74
+ expect(contentRect('cover', CENTER, 400, 300, 200, 100)).toEqual({
75
+ x: -100,
76
+ y: 0,
77
+ width: 600,
78
+ height: 300,
79
+ })
80
+ })
81
+
82
+ it('cover + top left anchors the crop', () => {
83
+ expect(contentRect('cover', { top: 0, left: 0 }, 400, 300, 200, 100)).toEqual({
84
+ x: 0,
85
+ y: 0,
86
+ width: 600,
87
+ height: 300,
88
+ })
89
+ })
90
+
91
+ it('cover + right 25% shifts into the overflow', () => {
92
+ expect(contentRect('cover', { right: '25%', top: '50%' }, 400, 300, 200, 100).x).toBe(-150)
93
+ })
94
+
95
+ it('fill ignores position entirely', () => {
96
+ expect(contentRect('fill', { top: 0, left: 0 }, 400, 300, 200, 100)).toEqual({
97
+ x: 0,
98
+ y: 0,
99
+ width: 400,
100
+ height: 300,
101
+ })
102
+ })
103
+
104
+ it('none keeps the intrinsic size, centered', () => {
105
+ expect(contentRect('none', CENTER, 400, 300, 200, 100)).toEqual({
106
+ x: 100,
107
+ y: 100,
108
+ width: 200,
109
+ height: 100,
110
+ })
111
+ })
112
+
113
+ it('scale-down shrinks to contain when the image is bigger, else none', () => {
114
+ expect(contentRect('scale-down', CENTER, 400, 300, 800, 600)).toEqual({
115
+ x: 0,
116
+ y: 0,
117
+ width: 400,
118
+ height: 300,
119
+ })
120
+
121
+ expect(contentRect('scale-down', CENTER, 400, 300, 200, 100)).toEqual({
122
+ x: 100,
123
+ y: 100,
124
+ width: 200,
125
+ height: 100,
126
+ })
127
+ })
128
+
129
+ it('absolute dip positions pin to the named edge', () => {
130
+ const rect = contentRect('contain', { bottom: 10, right: 20 }, 400, 300, 200, 100)
131
+ // content is 400x200 → bottom-edge offset = 300-200-10, right = 400-400-20
132
+ expect(rect.y).toBe(90)
133
+ expect(rect.x).toBe(-20)
134
+ })
135
+ })
136
+
137
+ describe('needsContentRect / contentFitToStretch', () => {
138
+ it('uses the fast path for expressible fits', () => {
139
+ expect(needsContentRect('cover', CENTER)).toBe(false)
140
+ expect(needsContentRect('contain', CENTER)).toBe(false)
141
+ expect(needsContentRect('fill', CENTER)).toBe(false)
142
+ // fill's rect is the whole box — position can't change it
143
+ expect(needsContentRect('fill', { top: 0, left: 0 })).toBe(false)
144
+ })
145
+
146
+ it('needs the computed path for none, scale-down, and positioned content', () => {
147
+ expect(needsContentRect('none', CENTER)).toBe(true)
148
+ expect(needsContentRect('scale-down', CENTER)).toBe(true)
149
+ expect(needsContentRect('cover', { top: 0 })).toBe(true)
150
+ expect(needsContentRect('contain', { left: '25%' })).toBe(true)
151
+ })
152
+
153
+ it('maps fits onto the stretch vocabulary', () => {
154
+ expect(contentFitToStretch('contain')).toBe('aspectFit')
155
+ expect(contentFitToStretch('cover')).toBe('aspectFill')
156
+ expect(contentFitToStretch('fill')).toBe('fill')
157
+ })
158
+ })
159
+
160
+ describe('bestImageSource', () => {
161
+ const sources = [
162
+ { uri: 'https://img.example/photo-100.jpg', width: 100, height: 100 },
163
+ { uri: 'https://img.example/photo-400.jpg', width: 400, height: 400 },
164
+ { uri: 'https://img.example/photo-1600.jpg', width: 1600, height: 1600 },
165
+ ]
166
+
167
+ it('picks the closest pixel count to the view', () => {
168
+ // 200x200 dips at scale 3 → 600x600 device px → closest is 400²
169
+ const best = bestImageSource(sources, 200, 200, 3)
170
+ expect(best?.uri).toBe('https://img.example/photo-400.jpg')
171
+ })
172
+
173
+ it('weighs the source scale multiplier', () => {
174
+ const dense = [
175
+ { uri: 'a', width: 100, height: 100, scale: 1 },
176
+ { uri: 'b', width: 100, height: 100, scale: 3 },
177
+ ]
178
+
179
+ // 100 dips at scale 3 → 300px target; b is 300px-worth, a is 100
180
+ expect(bestImageSource(dense, 100, 100, 3)?.uri).toBe('b')
181
+ })
182
+
183
+ it('returns the only source, and nothing before a real layout', () => {
184
+ expect(bestImageSource(sources, 0, 0, 3)).toBeNull()
185
+ expect(bestImageSource([sources[0]], 100, 100, 1)?.uri).toBe(sources[0].uri)
186
+ })
187
+
188
+ it('skips unsized sources when sized candidates exist', () => {
189
+ const mixed = [{ uri: 'unsized' }, sources[1]]
190
+ expect(bestImageSource(mixed, 100, 100, 3)?.uri).toBe(sources[1].uri)
191
+ })
192
+ })
193
+
194
+ describe('objectPositionCSSValue / imageSrcSet', () => {
195
+ it('emits edge-value object-position', () => {
196
+ expect(objectPositionCSSValue({ top: 0, right: '25%' })).toBe('top 0px right 25%')
197
+ expect(objectPositionCSSValue({ bottom: 10, left: '50%' })).toBe('bottom 10px left 50%')
198
+ expect(objectPositionCSSValue({})).toBe('top 50% left 50%')
199
+ })
200
+
201
+ it('builds w-descriptor srcset from width·scale', () => {
202
+ const { src, srcSet } = imageSrcSet([
203
+ { uri: 'a.jpg', width: 100, height: 100 },
204
+ { uri: 'b.jpg', width: 200, height: 200, scale: 2 },
205
+ ])
206
+
207
+ expect(srcSet).toBe('a.jpg 100w, b.jpg 400w')
208
+ expect(src).toBe('b.jpg')
209
+ })
210
+
211
+ it('builds x-descriptor srcset when only density is known', () => {
212
+ const { srcSet } = imageSrcSet([
213
+ { uri: 'a.png', scale: 1 },
214
+ { uri: 'a@2x.png', scale: 2 },
215
+ ])
216
+
217
+ expect(srcSet).toBe('a.png 1x, a@2x.png 2x')
218
+ })
219
+ })