@react-native-gpui/svg 0.0.0-stage → 15.15.0-alpha.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.
@@ -0,0 +1,609 @@
1
+ use gpui::{div, prelude::*, px, AnyElement, App, Window};
2
+ use react_native_gpui::extensions::{
3
+ register_component_renderer, ComponentNode, ComponentRenderer,
4
+ };
5
+ use rn_gpui_sys::ffi::RectF;
6
+ use std::sync::Arc;
7
+
8
+ mod cache;
9
+ mod key;
10
+ mod raster;
11
+
12
+ const COMPONENTS: &[&str] = &[
13
+ "RNSVGSvgView",
14
+ "RNSVGCircle",
15
+ "RNSVGEllipse",
16
+ "RNSVGLine",
17
+ "RNSVGPath",
18
+ "RNSVGRect",
19
+ "RNSVGGroup",
20
+ "RNSVGDefs",
21
+ "RNSVGClipPath",
22
+ "RNSVGLinearGradient",
23
+ "RNSVGRadialGradient",
24
+ "RNSVGText",
25
+ "RNSVGTSpan",
26
+ "RNSVGMask",
27
+ "RNSVGSymbol",
28
+ "RNSVGPattern",
29
+ "RNSVGMarker",
30
+ "RNSVGTextPath",
31
+ "RNSVGUse",
32
+ "RNSVGFilter",
33
+ "RNSVGFeBlend",
34
+ "RNSVGFeColorMatrix",
35
+ "RNSVGFeComposite",
36
+ "RNSVGFeFlood",
37
+ "RNSVGFeGaussianBlur",
38
+ "RNSVGFeMerge",
39
+ "RNSVGFeOffset",
40
+ ];
41
+
42
+ struct SvgRenderer;
43
+
44
+ impl ComponentRenderer for SvgRenderer {
45
+ // The SVG document is drawn from the shapes under its root.
46
+ fn reads_children(&self) -> bool {
47
+ true
48
+ }
49
+
50
+ fn render(
51
+ &self,
52
+ node: &ComponentNode,
53
+ window: &mut Window,
54
+ cx: &mut App,
55
+ ) -> Option<AnyElement> {
56
+ if node.component != "RNSVGSvgView" {
57
+ return Some(div().into_any_element());
58
+ }
59
+ // The frame is in unscaled coordinates and this element is painted
60
+ // scaled, exactly as `ComponentRenderer::render` documents, so the box
61
+ // has to be scaled the same way core scales the element it would have
62
+ // built. `size_full()` sized the image to its parent instead: the
63
+ // document is rasterized at the window's scale factor, and an image
64
+ // carries no intrinsic size, so every SVG stretched to fill whatever
65
+ // surrounded it and painted over the text below it.
66
+ let frame = node.frame;
67
+ let (scale_x, scale_y) = node.scale;
68
+ let width = frame.w * scale_x;
69
+ let height = frame.h * scale_y;
70
+ let scale = window.scale_factor();
71
+ let image = raster::image_for(node, frame, scale, window, cx);
72
+ // Chrome paints an inline SVG at a whole CSS pixel: a box laid out at
73
+ // 2.5 is drawn at 3. The image is placed the same way, so a 15 point
74
+ // icon centred in a 20 point button lands where the web app's does.
75
+ Some(
76
+ gpui::canvas(
77
+ |bounds, _, _| bounds,
78
+ move |_, bounds, window, _| {
79
+ let snapped = gpui::Bounds::new(
80
+ gpui::point(
81
+ px((bounds.origin.x.as_f32() + 0.5).floor()),
82
+ px((bounds.origin.y.as_f32() + 0.5).floor()),
83
+ ),
84
+ bounds.size,
85
+ );
86
+ if let Some(image) = &image {
87
+ let _ = window.paint_image(
88
+ snapped,
89
+ snapped,
90
+ gpui::Corners::default(),
91
+ image.clone(),
92
+ 0,
93
+ false,
94
+ );
95
+ }
96
+ },
97
+ )
98
+ .w(px(width))
99
+ .h(px(height))
100
+ .into_any_element(),
101
+ )
102
+ }
103
+ }
104
+
105
+ pub fn rn_gpui_register() {
106
+ for component in COMPONENTS {
107
+ register_component_renderer(*component, Arc::new(SvgRenderer));
108
+ }
109
+ }
110
+
111
+ /// Where one coverage sample sits, so a shape can be drawn once per sample.
112
+ #[derive(Clone, Copy)]
113
+ pub(crate) struct Sample {
114
+ /// The sample's offset from the pixel centre, in device pixels.
115
+ pub(crate) dx: f32,
116
+ pub(crate) dy: f32,
117
+ /// Device pixels per CSS pixel.
118
+ pub(crate) scale: f32,
119
+ }
120
+
121
+ /// Chrome rasterises with four coverage samples per pixel at these offsets (in
122
+ /// pixels from the centre): the standard Direct3D 4x pattern. A pixel is
123
+ /// therefore never covered by anything but 0, 1/4, 1/2, 3/4 or all, and the edge
124
+ /// of an icon takes the same five steps here.
125
+ pub(crate) const SAMPLE_OFFSETS: [(f32, f32); 4] = [
126
+ (-2.0 / 16.0, -6.0 / 16.0),
127
+ (6.0 / 16.0, -2.0 / 16.0),
128
+ (-6.0 / 16.0, 2.0 / 16.0),
129
+ (2.0 / 16.0, 6.0 / 16.0),
130
+ ];
131
+
132
+ pub(crate) fn svg_document(node: &ComponentNode, frame: RectF, sample: Option<Sample>) -> String {
133
+ let props = &node.props;
134
+ let mut attributes = format!(
135
+ "xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\"",
136
+ svg_number(frame.w),
137
+ svg_number(frame.h)
138
+ );
139
+ if sample.is_some() {
140
+ attributes.push_str(" shape-rendering=\"crispEdges\"");
141
+ }
142
+ for (name, attribute) in [
143
+ ("color", "color"),
144
+ ("fill", "fill"),
145
+ ("stroke", "stroke"),
146
+ ("strokeWidth", "stroke-width"),
147
+ ("fillOpacity", "fill-opacity"),
148
+ ("strokeOpacity", "stroke-opacity"),
149
+ ("opacity", "opacity"),
150
+ ] {
151
+ if let Some(value) = props.get(name).filter(|value| !value.is_null()) {
152
+ if let Some(value) = svg_value(value) {
153
+ attributes.push_str(&format!(" {attribute}=\"{}\"", svg_escape(&value)));
154
+ }
155
+ }
156
+ }
157
+ if let Some(cap) = props
158
+ .get("strokeLinecap")
159
+ .and_then(serde_json::Value::as_i64)
160
+ {
161
+ let cap = ["butt", "round", "square"]
162
+ .get(cap as usize)
163
+ .copied()
164
+ .unwrap_or("butt");
165
+ attributes.push_str(&format!(" stroke-linecap=\"{cap}\""));
166
+ }
167
+ if let Some(join) = props
168
+ .get("strokeLinejoin")
169
+ .and_then(serde_json::Value::as_i64)
170
+ {
171
+ let join = ["miter", "round", "bevel"]
172
+ .get(join as usize)
173
+ .copied()
174
+ .unwrap_or("miter");
175
+ attributes.push_str(&format!(" stroke-linejoin=\"{join}\""));
176
+ }
177
+ let view_box = match (
178
+ props.get("vbWidth").and_then(serde_json::Value::as_f64),
179
+ props.get("vbHeight").and_then(serde_json::Value::as_f64),
180
+ ) {
181
+ (Some(width), Some(height)) if width > 0.0 && height > 0.0 => {
182
+ let min_x = props
183
+ .get("minX")
184
+ .and_then(serde_json::Value::as_f64)
185
+ .unwrap_or(0.0);
186
+ let min_y = props
187
+ .get("minY")
188
+ .and_then(serde_json::Value::as_f64)
189
+ .unwrap_or(0.0);
190
+ let align = props.get("align").and_then(serde_json::Value::as_str);
191
+ let mode = match props
192
+ .get("meetOrSlice")
193
+ .and_then(serde_json::Value::as_i64)
194
+ .unwrap_or(0)
195
+ {
196
+ 1 => "slice",
197
+ 2 => "none",
198
+ _ => "meet",
199
+ };
200
+ Some((min_x, min_y, width, height, align, mode))
201
+ }
202
+ _ => None,
203
+ };
204
+ match (view_box, sample) {
205
+ (Some((min_x, min_y, width, height, align, mode)), sample) => {
206
+ // The sample's shift in user units: how many of them one device
207
+ // pixel covers depends on how the view box is fitted to the frame.
208
+ let (mut min_x, mut min_y) = (min_x, min_y);
209
+ if let Some(sample) = sample {
210
+ let fit_x = f64::from(frame.w * sample.scale) / width;
211
+ let fit_y = f64::from(frame.h * sample.scale) / height;
212
+ let (unit_x, unit_y) = match (align, mode) {
213
+ (Some("none"), _) | (Some(_), "none") => (fit_x, fit_y),
214
+ (_, "slice") => (fit_x.max(fit_y), fit_x.max(fit_y)),
215
+ _ => (fit_x.min(fit_y), fit_x.min(fit_y)),
216
+ };
217
+ min_x += f64::from(sample.dx) / unit_x;
218
+ min_y += f64::from(sample.dy) / unit_y;
219
+ }
220
+ attributes.push_str(&format!(
221
+ " viewBox=\"{} {} {} {}\"",
222
+ svg_number(min_x as f32),
223
+ svg_number(min_y as f32),
224
+ svg_number(width as f32),
225
+ svg_number(height as f32)
226
+ ));
227
+ if let Some(align) = align {
228
+ attributes.push_str(&format!(
229
+ " preserveAspectRatio=\"{} {}\"",
230
+ svg_escape(align),
231
+ mode
232
+ ));
233
+ }
234
+ }
235
+ (None, Some(sample)) => {
236
+ // Without a view box the user unit is the CSS pixel; one is added
237
+ // only to carry the shift.
238
+ attributes.push_str(&format!(
239
+ " viewBox=\"{} {} {} {}\"",
240
+ svg_number(sample.dx / sample.scale),
241
+ svg_number(sample.dy / sample.scale),
242
+ svg_number(frame.w),
243
+ svg_number(frame.h)
244
+ ));
245
+ }
246
+ (None, None) => {}
247
+ }
248
+ let mut document = format!("<svg {attributes}>");
249
+ for child in &node.children {
250
+ svg_node(child, &mut document);
251
+ }
252
+ document.push_str("</svg>");
253
+ document
254
+ }
255
+
256
+ fn svg_node(node: &ComponentNode, output: &mut String) {
257
+ let props = &node.props;
258
+ let element = match node.component.as_str() {
259
+ "RNSVGCircle" => "circle",
260
+ "RNSVGEllipse" => "ellipse",
261
+ "RNSVGLine" => "line",
262
+ "RNSVGPath" => "path",
263
+ "RNSVGRect" => "rect",
264
+ "RNSVGGroup" => "g",
265
+ "RNSVGDefs" => "defs",
266
+ "RNSVGClipPath" => "clipPath",
267
+ "RNSVGLinearGradient" => "linearGradient",
268
+ "RNSVGRadialGradient" => "radialGradient",
269
+ "RNSVGMask" => "mask",
270
+ "RNSVGSymbol" => "symbol",
271
+ "RNSVGPattern" => "pattern",
272
+ "RNSVGMarker" => "marker",
273
+ "RNSVGText" => "text",
274
+ "RNSVGTSpan" => "tspan",
275
+ "RNSVGTextPath" => "textPath",
276
+ "RNSVGUse" => "use",
277
+ "RNSVGFilter" => "filter",
278
+ "RNSVGFeBlend" => "feBlend",
279
+ "RNSVGFeColorMatrix" => "feColorMatrix",
280
+ "RNSVGFeComposite" => "feComposite",
281
+ "RNSVGFeFlood" => "feFlood",
282
+ "RNSVGFeGaussianBlur" => "feGaussianBlur",
283
+ "RNSVGFeMerge" => "feMerge",
284
+ "RNSVGFeOffset" => "feOffset",
285
+ _ => "",
286
+ };
287
+ if element.is_empty() {
288
+ for child in &node.children {
289
+ svg_node(child, output);
290
+ }
291
+ return;
292
+ }
293
+ let mut attributes = String::new();
294
+ for name in [
295
+ "id",
296
+ "name",
297
+ "href",
298
+ "d",
299
+ "x",
300
+ "y",
301
+ "x1",
302
+ "y1",
303
+ "x2",
304
+ "y2",
305
+ "cx",
306
+ "cy",
307
+ "r",
308
+ "rx",
309
+ "ry",
310
+ "width",
311
+ "height",
312
+ "color",
313
+ "fill",
314
+ "stroke",
315
+ "strokeWidth",
316
+ "fillOpacity",
317
+ "strokeOpacity",
318
+ "strokeDasharray",
319
+ "strokeDashoffset",
320
+ "strokeMiterlimit",
321
+ "opacity",
322
+ "fontSize",
323
+ "fontWeight",
324
+ "fontFamily",
325
+ "textLength",
326
+ "dominantBaseline",
327
+ "offset",
328
+ "stopColor",
329
+ "stopOpacity",
330
+ ] {
331
+ if !svg_property_is_set(props, name) {
332
+ continue;
333
+ }
334
+ if let Some(value) = props.get(name) {
335
+ let attribute = match name {
336
+ "strokeWidth" => "stroke-width",
337
+ "fillOpacity" => "fill-opacity",
338
+ "strokeOpacity" => "stroke-opacity",
339
+ "strokeDasharray" => "stroke-dasharray",
340
+ "strokeDashoffset" => "stroke-dashoffset",
341
+ "strokeMiterlimit" => "stroke-miterlimit",
342
+ "fontSize" => "font-size",
343
+ "fontWeight" => "font-weight",
344
+ "fontFamily" => "font-family",
345
+ "dominantBaseline" => "dominant-baseline",
346
+ "stopColor" => "stop-color",
347
+ "stopOpacity" => "stop-opacity",
348
+ other => other,
349
+ };
350
+ let value = if value.is_null() && matches!(name, "fill" | "stroke") {
351
+ Some("none".to_owned())
352
+ } else {
353
+ svg_value(value)
354
+ };
355
+ if let Some(value) = value {
356
+ attributes.push_str(&format!(" {attribute}=\"{}\"", svg_escape(&value)));
357
+ }
358
+ }
359
+ }
360
+ if let Some(rule) = props
361
+ .get("fillRule")
362
+ .filter(|_| svg_property_is_set(props, "fillRule"))
363
+ .and_then(serde_json::Value::as_i64)
364
+ {
365
+ attributes.push_str(&format!(
366
+ " fill-rule=\"{}\"",
367
+ if rule == 0 { "evenodd" } else { "nonzero" }
368
+ ));
369
+ }
370
+ if let Some(cap) = props
371
+ .get("strokeLinecap")
372
+ .filter(|_| svg_property_is_set(props, "strokeLinecap"))
373
+ .and_then(serde_json::Value::as_i64)
374
+ {
375
+ let cap = ["butt", "round", "square"]
376
+ .get(cap as usize)
377
+ .copied()
378
+ .unwrap_or("butt");
379
+ attributes.push_str(&format!(" stroke-linecap=\"{cap}\""));
380
+ }
381
+ if let Some(join) = props
382
+ .get("strokeLinejoin")
383
+ .filter(|_| svg_property_is_set(props, "strokeLinejoin"))
384
+ .and_then(serde_json::Value::as_i64)
385
+ {
386
+ let join = ["miter", "round", "bevel"]
387
+ .get(join as usize)
388
+ .copied()
389
+ .unwrap_or("miter");
390
+ attributes.push_str(&format!(" stroke-linejoin=\"{join}\""));
391
+ }
392
+ if let Some(matrix) = props.get("matrix").and_then(serde_json::Value::as_array) {
393
+ let component = |index: usize| matrix.get(index).and_then(serde_json::Value::as_f64);
394
+ let values = match matrix.len() {
395
+ 6 => [0, 1, 2, 3, 4, 5],
396
+ 16.. => [0, 1, 4, 5, 12, 13],
397
+ _ => [usize::MAX; 6],
398
+ }
399
+ .map(component);
400
+ if let [Some(a), Some(b), Some(c), Some(d), Some(e), Some(f)] = values {
401
+ attributes.push_str(&format!(" transform=\"matrix({a} {b} {c} {d} {e} {f})\""));
402
+ }
403
+ }
404
+ if let Some(id) = props
405
+ .get("name")
406
+ .and_then(serde_json::Value::as_str)
407
+ .filter(|id| !id.is_empty())
408
+ {
409
+ attributes.push_str(&format!(" id=\"{}\"", svg_escape(id)));
410
+ }
411
+ if let Some(filter) = props
412
+ .get("filter")
413
+ .and_then(serde_json::Value::as_str)
414
+ .filter(|filter| !filter.is_empty())
415
+ {
416
+ attributes.push_str(&format!(" filter=\"url(#{})\"", svg_escape(filter)));
417
+ }
418
+ if element == "filter" {
419
+ attributes.push_str(" color-interpolation-filters=\"sRGB\"");
420
+ }
421
+ if element.starts_with("fe") || element == "filter" {
422
+ attributes.push_str(&filter_primitive_attributes(props));
423
+ }
424
+ output.push('<');
425
+ output.push_str(element);
426
+ output.push_str(&attributes);
427
+ output.push('>');
428
+ if element == "feMerge" {
429
+ for id in props
430
+ .get("nodes")
431
+ .and_then(serde_json::Value::as_array)
432
+ .into_iter()
433
+ .flatten()
434
+ .filter_map(serde_json::Value::as_str)
435
+ {
436
+ output.push_str(&format!("<feMergeNode in=\"{}\"/>", svg_escape(id)));
437
+ }
438
+ }
439
+ for child in &node.children {
440
+ svg_node(child, output);
441
+ }
442
+ output.push_str("</");
443
+ output.push_str(element);
444
+ output.push('>');
445
+ }
446
+
447
+ fn filter_primitive_attributes(props: &serde_json::Value) -> String {
448
+ let mut attributes = String::new();
449
+ let text = |name: &str| match props.get(name) {
450
+ Some(serde_json::Value::String(value)) if !value.is_empty() => Some(value.clone()),
451
+ Some(value) if value.is_array() || value.is_number() => svg_value(value),
452
+ _ => None,
453
+ };
454
+ for (name, attribute) in [
455
+ ("in1", "in"),
456
+ ("in2", "in2"),
457
+ ("result", "result"),
458
+ ("mode", "mode"),
459
+ ("type", "type"),
460
+ ("values", "values"),
461
+ ("operator1", "operator"),
462
+ ("k1", "k1"),
463
+ ("k2", "k2"),
464
+ ("k3", "k3"),
465
+ ("k4", "k4"),
466
+ ("dx", "dx"),
467
+ ("dy", "dy"),
468
+ ("edgeMode", "edgeMode"),
469
+ ("floodOpacity", "flood-opacity"),
470
+ ("filterUnits", "filterUnits"),
471
+ ("primitiveUnits", "primitiveUnits"),
472
+ ] {
473
+ if let Some(value) = text(name) {
474
+ attributes.push_str(&format!(" {attribute}=\"{}\"", svg_escape(&value)));
475
+ }
476
+ }
477
+ if let Some(color) = props.get("floodColor").and_then(svg_value) {
478
+ attributes.push_str(&format!(" flood-color=\"{}\"", svg_escape(&color)));
479
+ }
480
+ let deviation = |name: &str| props.get(name).and_then(serde_json::Value::as_f64);
481
+ if let Some(x) = deviation("stdDeviationX") {
482
+ let y = deviation("stdDeviationY").unwrap_or(x);
483
+ attributes.push_str(&format!(" stdDeviation=\"{x} {y}\""));
484
+ }
485
+ attributes
486
+ }
487
+
488
+ fn svg_property_is_set(props: &serde_json::Value, name: &str) -> bool {
489
+ if !matches!(
490
+ name,
491
+ "fill"
492
+ | "fillOpacity"
493
+ | "fillRule"
494
+ | "stroke"
495
+ | "strokeWidth"
496
+ | "strokeOpacity"
497
+ | "strokeLinecap"
498
+ | "strokeLinejoin"
499
+ | "strokeDasharray"
500
+ | "strokeDashoffset"
501
+ | "strokeMiterlimit"
502
+ ) {
503
+ return true;
504
+ }
505
+ props
506
+ .get("propList")
507
+ .and_then(serde_json::Value::as_array)
508
+ .is_none_or(|properties| {
509
+ properties
510
+ .iter()
511
+ .any(|property| property.as_str() == Some(name))
512
+ })
513
+ }
514
+
515
+ fn svg_value(value: &serde_json::Value) -> Option<String> {
516
+ match value.get("type").and_then(serde_json::Value::as_i64) {
517
+ Some(1) => {
518
+ return value
519
+ .get("brushRef")
520
+ .and_then(serde_json::Value::as_str)
521
+ .map(|id| format!("url(#{id})"))
522
+ }
523
+ Some(2) => return Some("currentColor".to_owned()),
524
+ Some(3) => return Some("context-fill".to_owned()),
525
+ Some(4) => return Some("context-stroke".to_owned()),
526
+ _ => {}
527
+ }
528
+
529
+ if let Some(number) = value.as_f64() {
530
+ if number < 0.0 && number.fract() == 0.0 {
531
+ return Some(format!("#{:06x}", (number as i64 as u32) & 0x00ff_ffff));
532
+ }
533
+ if (0x0100_0000..=0xffff_ffff).contains(&(number as u64)) {
534
+ let argb = number as u32;
535
+ return Some(format!(
536
+ "rgba({}, {}, {}, {})",
537
+ (argb >> 16) & 0xff,
538
+ (argb >> 8) & 0xff,
539
+ argb & 0xff,
540
+ ((argb >> 24) & 0xff) as f32 / 255.0
541
+ ));
542
+ }
543
+ return Some(svg_number(number as f32));
544
+ }
545
+ if let Some(value) = value.as_str() {
546
+ return Some(value.to_owned());
547
+ }
548
+ if let Some(value) = value.as_array() {
549
+ return Some(
550
+ value
551
+ .iter()
552
+ .filter_map(svg_value)
553
+ .collect::<Vec<_>>()
554
+ .join(" "),
555
+ );
556
+ }
557
+ value
558
+ .get("payload")
559
+ .and_then(svg_value)
560
+ .or_else(|| value.get("value").and_then(svg_value))
561
+ }
562
+
563
+ fn svg_escape(value: &str) -> String {
564
+ value
565
+ .replace('&', "&amp;")
566
+ .replace('"', "&quot;")
567
+ .replace('<', "&lt;")
568
+ .replace('>', "&gt;")
569
+ }
570
+
571
+ fn svg_number(value: f32) -> String {
572
+ if value.fract() == 0.0 {
573
+ format!("{value:.0}")
574
+ } else {
575
+ value.to_string()
576
+ }
577
+ }
578
+
579
+ #[cfg(test)]
580
+ mod svg_paint_tests {
581
+ use super::{svg_property_is_set, svg_value};
582
+ use serde_json::json;
583
+
584
+ #[test]
585
+ fn brushes_keep_current_color_and_reference_semantics() {
586
+ assert_eq!(
587
+ svg_value(&json!({"type": 2})).as_deref(),
588
+ Some("currentColor")
589
+ );
590
+ assert_eq!(
591
+ svg_value(&json!({"type": 1, "brushRef": "gradient"})).as_deref(),
592
+ Some("url(#gradient)")
593
+ );
594
+ assert_eq!(
595
+ svg_value(&json!({"type": 0, "payload": 0xff64d2ff_u32})).as_deref(),
596
+ Some("rgba(100, 210, 255, 1)")
597
+ );
598
+ }
599
+
600
+ #[test]
601
+ fn default_black_fill_does_not_override_inherited_none() {
602
+ let child = json!({"fill": {"type": 0, "payload": 0xff000000_u32}, "strokeLinecap": 0, "propList": []});
603
+ assert!(!svg_property_is_set(&child, "fill"));
604
+ assert!(!svg_property_is_set(&child, "strokeLinecap"));
605
+ let group = json!({"fill": null, "stroke": {"type": 2}, "propList": ["fill", "stroke"]});
606
+ assert!(svg_property_is_set(&group, "fill"));
607
+ assert!(svg_property_is_set(&group, "stroke"));
608
+ }
609
+ }