@react-native-gpui/screens 0.0.0-stage → 4.28.0-alpha.0

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,829 @@
1
+ //! Painting react-native-screens through the generic renderer hooks.
2
+ //!
3
+ //! Core builds a box, a background and an accessibility node for every
4
+ //! component. What is screens-specific — which screen is on top, where a header
5
+ //! subview sits, whether a card is showing — is decided here, from the props this
6
+ //! module's C++ half produced and a read-only view of the tree.
7
+ use crate::props;
8
+ use crate::stack::{
9
+ CONTENT_WRAPPER, FULL_WINDOW_OVERLAY, HEADER_CONFIG, HEADER_SUBVIEW, MODAL_SCREEN, SCREEN,
10
+ STACK,
11
+ };
12
+ use gpui::{div, prelude::*, AnyElement, App, MouseButton, SharedString, Window};
13
+ use react_native_gpui::extensions::{
14
+ store_handle, ComponentNode, ComponentRenderer, Decoration, Portal, StoreHandle, TreeView,
15
+ };
16
+ use rn_gpui_sys::ffi::RectF;
17
+
18
+ /// The header bar's height, which the content wrapper is inset by.
19
+ pub const HEADER_HEIGHT: f32 = 56.0;
20
+
21
+ /// A screen inside a stack.
22
+ pub struct ScreenRenderer;
23
+
24
+ impl ComponentRenderer for ScreenRenderer {
25
+ fn render(
26
+ &self,
27
+ _node: &ComponentNode,
28
+ _window: &mut Window,
29
+ _cx: &mut App,
30
+ ) -> Option<AnyElement> {
31
+ None
32
+ }
33
+
34
+ fn children(
35
+ &self,
36
+ node: &ComponentNode,
37
+ children: Vec<AnyElement>,
38
+ _window: &mut Window,
39
+ _cx: &mut App,
40
+ ) -> Vec<AnyElement> {
41
+ if !props::is_modal_presentation(node) {
42
+ return children;
43
+ }
44
+ let material = props::is_transparent_presentation(node);
45
+ // The frame is in unscaled coordinates and the element this returns is
46
+ // painted scaled, so the sheet has to be scaled to land where the card
47
+ // it covers does. The old code did the same with `scale_x`/`scale_y`.
48
+ let (scale_x, scale_y) = node.scale;
49
+ let width = (node.frame.w - SHEET_MARGIN).clamp(0.0, SHEET_MAX_WIDTH) * scale_x;
50
+ let height = (node.frame.h - SHEET_MARGIN).clamp(0.0, SHEET_MAX_HEIGHT) * scale_y;
51
+ let left = ((node.frame.w * scale_x - width) * 0.5).max(0.0);
52
+ let top = ((node.frame.h * scale_y - height) * 0.5).max(0.0);
53
+ let mut layers = Vec::with_capacity(2);
54
+ if !material {
55
+ layers.push(
56
+ div()
57
+ .absolute()
58
+ .inset_0()
59
+ .size_full()
60
+ .bg(argb(0x80000000))
61
+ .into_any_element(),
62
+ );
63
+ }
64
+ layers.push(
65
+ div()
66
+ .absolute()
67
+ .left(gpui::px(left))
68
+ .top(gpui::px(top))
69
+ .w(gpui::px(width))
70
+ .h(gpui::px(height))
71
+ .overflow_hidden()
72
+ .bg(argb(if material { 0x00000000 } else { 0xff171923 }))
73
+ .children(children)
74
+ .into_any_element(),
75
+ );
76
+ layers
77
+ }
78
+
79
+ fn hides_subtree(&self, node: &ComponentNode, tree: &TreeView) -> bool {
80
+ if props::activity_state(node) > 0.0 {
81
+ return false;
82
+ }
83
+ if tree
84
+ .transition(node.tag)
85
+ .is_some_and(|state| state.progress < 1.0)
86
+ {
87
+ return false;
88
+ }
89
+ !covered_by_modal_sibling(node, tree)
90
+ }
91
+
92
+ fn decorate(&self, node: &ComponentNode, tree: &TreeView) -> Decoration {
93
+ if props::activity_state(node) > 0.0 {
94
+ return Decoration::default();
95
+ }
96
+ let transitioning = tree
97
+ .transition(node.tag)
98
+ .is_some_and(|state| state.progress < 1.0);
99
+ let visible_under_modal = covered_by_modal_sibling(node, tree);
100
+ if !transitioning && !visible_under_modal {
101
+ return Decoration {
102
+ display: Some(false),
103
+ ..Decoration::default()
104
+ };
105
+ }
106
+ Decoration::default()
107
+ }
108
+ }
109
+
110
+ /// Whether a later sibling in the same stack is an active modal presentation.
111
+ fn covered_by_modal_sibling(node: &ComponentNode, tree: &TreeView) -> bool {
112
+ let Some(parent) = node.parent else {
113
+ return false;
114
+ };
115
+ let siblings = tree.children(parent);
116
+ let Some(position) = siblings.iter().position(|tag| *tag == node.tag) else {
117
+ return false;
118
+ };
119
+ siblings
120
+ .into_iter()
121
+ .skip(position + 1)
122
+ .filter_map(|tag| tree.node(tag))
123
+ .any(|sibling| {
124
+ sibling.component == SCREEN
125
+ && props::activity_state(&sibling) > 0.0
126
+ && props::is_modal_presentation(&sibling)
127
+ })
128
+ }
129
+
130
+ /// The stack itself: it clips, and it owns the Escape gesture.
131
+ pub struct StackRenderer;
132
+
133
+ impl ComponentRenderer for StackRenderer {
134
+ fn render(
135
+ &self,
136
+ _node: &ComponentNode,
137
+ _window: &mut Window,
138
+ _cx: &mut App,
139
+ ) -> Option<AnyElement> {
140
+ None
141
+ }
142
+
143
+ fn decorate(&self, _node: &ComponentNode, _tree: &TreeView) -> Decoration {
144
+ Decoration {
145
+ clip: true,
146
+ ..Decoration::default()
147
+ }
148
+ }
149
+
150
+ fn handles_keys(&self, _node: &ComponentNode) -> bool {
151
+ true
152
+ }
153
+
154
+ fn on_key_down(
155
+ &self,
156
+ node: &ComponentNode,
157
+ key: &str,
158
+ tree: &TreeView,
159
+ handle: &react_native_gpui::extensions::StoreHandle,
160
+ window: &mut Window,
161
+ cx: &mut App,
162
+ ) -> bool {
163
+ if key != "escape" {
164
+ return false;
165
+ }
166
+ if has_screen_ancestor(node, tree) {
167
+ return false;
168
+ }
169
+ let screens: Vec<i32> = tree
170
+ .children(node.tag)
171
+ .into_iter()
172
+ .filter(|tag| {
173
+ tree.is_live(*tag) && tree.node(*tag).is_some_and(|n| n.component == SCREEN)
174
+ })
175
+ .collect();
176
+ let Some(top) = screens.last().copied() else {
177
+ return false;
178
+ };
179
+ if crate::state::with(|state| state.native_dismiss_pending.is_some()) {
180
+ return true;
181
+ }
182
+ dismiss_top(top, node.tag, tree, handle, window, cx);
183
+ true
184
+ }
185
+ }
186
+
187
+ /// Whether any ancestor of `node` is a screen, i.e. this stack is nested.
188
+ fn has_screen_ancestor(node: &ComponentNode, tree: &TreeView) -> bool {
189
+ tree.ancestors(node.tag)
190
+ .into_iter()
191
+ .filter_map(|tag| tree.node(tag))
192
+ .any(|ancestor| ancestor.component == SCREEN)
193
+ }
194
+
195
+ /// The header bar: a background, a title and a back button.
196
+ pub struct HeaderConfigRenderer;
197
+
198
+ impl ComponentRenderer for HeaderConfigRenderer {
199
+ fn render(
200
+ &self,
201
+ _node: &ComponentNode,
202
+ _window: &mut Window,
203
+ _cx: &mut App,
204
+ ) -> Option<AnyElement> {
205
+ None
206
+ }
207
+
208
+ fn layout_frame(&self, node: &ComponentNode, tree: &TreeView) -> Option<RectF> {
209
+ let mut frame = node.frame;
210
+ frame.y += tree.window_top_inset();
211
+ frame.h = if props::header_hidden(node) || !is_top_header(node, tree) {
212
+ 0.0
213
+ } else {
214
+ HEADER_HEIGHT
215
+ };
216
+ Some(frame)
217
+ }
218
+
219
+ fn hides_subtree(&self, node: &ComponentNode, tree: &TreeView) -> bool {
220
+ props::header_hidden(node) || !is_top_header(node, tree)
221
+ }
222
+
223
+ fn decorate(&self, node: &ComponentNode, tree: &TreeView) -> Decoration {
224
+ let top = is_top_header(node, tree);
225
+ let shown = should_show_back(node, tree);
226
+ crate::state::set_header_facts(
227
+ node.tag,
228
+ top,
229
+ shown,
230
+ has_title_subview(node, tree),
231
+ node.parent,
232
+ );
233
+ if props::header_hidden(node) || !top {
234
+ return Decoration {
235
+ display: Some(false),
236
+ ..Decoration::default()
237
+ };
238
+ }
239
+ let background = first_nonzero(props::header_background_color(node), 0xff171923);
240
+ Decoration {
241
+ background: Some(background),
242
+ ..Decoration::default()
243
+ }
244
+ }
245
+
246
+ fn depends_on_window_insets(&self) -> bool {
247
+ true
248
+ }
249
+
250
+ fn content(
251
+ &self,
252
+ node: &ComponentNode,
253
+ _window: &mut Window,
254
+ _cx: &mut App,
255
+ ) -> Option<AnyElement> {
256
+ if props::header_hidden(node) || !crate::state::header_is_top(node.tag) {
257
+ return None;
258
+ }
259
+ // React Native declares `onHeaderBackButtonClicked` on the screen, so
260
+ // the config's parent is the tag that has a listener for it.
261
+ let Some(screen_tag) = crate::state::header_screen(node.tag) else {
262
+ return None;
263
+ };
264
+ let title_color = first_nonzero(props::header_title_color(node), 0xffffffff);
265
+ let mut bar = div()
266
+ .absolute()
267
+ .top_0()
268
+ .left_0()
269
+ .size_full()
270
+ .flex()
271
+ .items_center()
272
+ .justify_center()
273
+ .text_color(argb(title_color))
274
+ .text_size(gpui::px(18.0));
275
+ if !crate::state::header_has_title_subview(node.tag) {
276
+ bar = bar.child(SharedString::from(props::header_title(node).to_string()));
277
+ }
278
+ if crate::state::header_shows_back(node.tag) {
279
+ bar = bar.child(
280
+ div()
281
+ .absolute()
282
+ .left_0()
283
+ .top_0()
284
+ .h(gpui::px(HEADER_HEIGHT))
285
+ .w(gpui::px(HEADER_HEIGHT))
286
+ .flex()
287
+ .items_center()
288
+ .justify_center()
289
+ .text_color(argb(first_nonzero(
290
+ props::header_tint_color(node),
291
+ 0xffffffff,
292
+ )))
293
+ .text_size(gpui::px(30.0))
294
+ .cursor_pointer()
295
+ .child("‹")
296
+ .on_mouse_down(MouseButton::Left, move |_event, window, cx| {
297
+ if let Some(handle) = store_handle(cx) {
298
+ handle.dispatch_event(
299
+ screen_tag,
300
+ "headerBackButtonClicked",
301
+ &serde_json::json!({}),
302
+ cx,
303
+ );
304
+ handle.request_frame(window);
305
+ }
306
+ cx.stop_propagation();
307
+ }),
308
+ );
309
+ }
310
+ Some(bar.into_any_element())
311
+ }
312
+ }
313
+
314
+ /// Escape dismisses the top screen the way a native gesture would.
315
+ ///
316
+ /// The card runs its exit animation, the screen gets the `will` pair, and only
317
+ /// when the animation ends does it get the final progress, the `did` pair and
318
+ /// `topDismissed`. `native_dismiss_pending` tells the stack observer that this
319
+ /// dismissal already ran its animation, so it must not run a second one.
320
+ fn dismiss_top(
321
+ top: i32,
322
+ stack: i32,
323
+ tree: &TreeView,
324
+ handle: &StoreHandle,
325
+ window: &mut Window,
326
+ cx: &mut App,
327
+ ) {
328
+ crate::state::with(|state| state.native_dismiss_pending = Some(top));
329
+ let incoming = previous_screen(top, stack, tree);
330
+ crate::lifecycle::dispatch_will(handle, Some(top), incoming, cx);
331
+ // The animation belongs to the screen being dismissed, not to the stack: a
332
+ // stack carries no `stackAnimation` prop of its own. A dismissal plays the
333
+ // entry mirrored, which is what `reverse` is for.
334
+ let animation = tree
335
+ .node(top)
336
+ .map(|node| props::stack_animation(&node).to_string())
337
+ .unwrap_or_default();
338
+ let mode = crate::transition::transition_mode(&animation)
339
+ .filter(|_| tree.animations_enabled())
340
+ .map(crate::transition::TransitionMode::reverse);
341
+ let duration = mode.map(|mode| mode.duration());
342
+ if let Some(mode) = mode {
343
+ let exit = crate::transition::transition(mode, crate::transition::Role::Exit, 1.0);
344
+ crate::state::with(|state| {
345
+ state.roles.insert(top, (crate::state::Role::Exit, 1.0));
346
+ });
347
+ handle.start_transition(top, exit, cx);
348
+ // The card underneath comes back to rest from wherever the push left
349
+ // it. Without this it stays offset for the whole dismissal.
350
+ if let Some(incoming) = incoming {
351
+ handle.clear_transition(incoming, cx);
352
+ }
353
+ handle.request_frame(window);
354
+ }
355
+ let finish = move |handle: &StoreHandle, cx: &mut App| {
356
+ handle.dispatch_event(
357
+ top,
358
+ "topTransitionProgress",
359
+ &serde_json::json!({"progress": 1, "closing": 1, "goingForward": 0}),
360
+ cx,
361
+ );
362
+ crate::lifecycle::dispatch_did(handle, Some(top), incoming, cx);
363
+ handle.dispatch_event(
364
+ top,
365
+ "topDismissed",
366
+ &serde_json::json!({ "dismissCount": 1 }),
367
+ cx,
368
+ );
369
+ };
370
+ match duration {
371
+ Some(duration) => {
372
+ let handle = handle.clone();
373
+ handle.after(duration, cx, move |handle, cx| finish(handle, cx));
374
+ }
375
+ None => finish(handle, cx),
376
+ }
377
+ }
378
+
379
+ /// A Fabric colour as GPUI sees it, alpha included.
380
+ fn argb(color: u32) -> gpui::Hsla {
381
+ let alpha = color >> 24;
382
+ gpui::rgba((color << 8) | alpha).into()
383
+ }
384
+
385
+ fn first_nonzero(value: u32, fallback: u32) -> u32 {
386
+ if value == 0 {
387
+ fallback
388
+ } else {
389
+ value
390
+ }
391
+ }
392
+
393
+ /// Whether the screen has a previous live screen in its stack to go back to.
394
+ fn should_show_back(node: &ComponentNode, tree: &TreeView) -> bool {
395
+ if props::header_hide_back(node) {
396
+ return false;
397
+ }
398
+ let Some(screen) = node.parent else {
399
+ return false;
400
+ };
401
+ let Some(stack) = stack_of(screen, tree) else {
402
+ return false;
403
+ };
404
+ let screens: Vec<i32> = tree
405
+ .children(stack)
406
+ .into_iter()
407
+ .filter(|tag| tree.is_live(*tag) && tree.node(*tag).is_some_and(|n| n.component == SCREEN))
408
+ .collect();
409
+ screens
410
+ .iter()
411
+ .position(|tag| *tag == screen)
412
+ .is_some_and(|position| position > 0)
413
+ }
414
+
415
+ /// Whether this header belongs to the screen currently on top of its stack.
416
+ fn is_top_header(node: &ComponentNode, tree: &TreeView) -> bool {
417
+ let Some(parent) = node.parent else {
418
+ return true;
419
+ };
420
+ let Some(stack) = stack_of(parent, tree) else {
421
+ return true;
422
+ };
423
+ let screens: Vec<i32> = tree
424
+ .children(stack)
425
+ .into_iter()
426
+ .filter(|tag| tree.node(*tag).is_some_and(|n| n.component == SCREEN))
427
+ .collect();
428
+ let is_last = screens.last().copied() == Some(parent);
429
+ // A screen that is leaving keeps its header while its transition runs, so
430
+ // the header does not blink out before the card has finished moving. An
431
+ // entry transition is never cleared once it ends, so only a running one
432
+ // counts: otherwise the first screen ever pushed keeps its header for good.
433
+ is_last
434
+ || tree
435
+ .transition(parent)
436
+ .is_some_and(|state| state.progress < 1.0)
437
+ }
438
+
439
+ /// Whether the header config carries a subview that paints the title.
440
+ ///
441
+ /// React Navigation puts a custom title in a `title` subview, and a centred one
442
+ /// in a `center` subview; either way that subview paints the title, so core must
443
+ /// not paint a second copy.
444
+ fn has_title_subview(config: &ComponentNode, tree: &TreeView) -> bool {
445
+ tree.children(config.tag)
446
+ .into_iter()
447
+ .filter_map(|tag| tree.node(tag))
448
+ .any(|child| {
449
+ child.component == HEADER_SUBVIEW
450
+ && matches!(props::subview_type(&child), "title" | "center")
451
+ })
452
+ }
453
+
454
+ /// The live screen directly below `top` in its stack, if any.
455
+ fn previous_screen(top: i32, stack: i32, tree: &TreeView) -> Option<i32> {
456
+ let screens: Vec<i32> = tree
457
+ .children(stack)
458
+ .into_iter()
459
+ .filter(|tag| tree.is_live(*tag) && tree.node(*tag).is_some_and(|n| n.component == SCREEN))
460
+ .collect();
461
+ let index = screens.iter().position(|tag| *tag == top)?;
462
+ index.checked_sub(1).and_then(|i| screens.get(i)).copied()
463
+ }
464
+
465
+ /// The stack a screen sits in, walking up past intermediate containers.
466
+ fn stack_of(screen: i32, tree: &TreeView) -> Option<i32> {
467
+ tree.ancestors(screen)
468
+ .into_iter()
469
+ .find(|tag| tree.node(*tag).is_some_and(|n| n.component == STACK))
470
+ }
471
+
472
+ /// A header subview: positioned by its declared type.
473
+ pub struct HeaderSubviewRenderer;
474
+
475
+ impl ComponentRenderer for HeaderSubviewRenderer {
476
+ fn render(
477
+ &self,
478
+ _node: &ComponentNode,
479
+ _window: &mut Window,
480
+ _cx: &mut App,
481
+ ) -> Option<AnyElement> {
482
+ None
483
+ }
484
+
485
+ fn layout_frame(&self, node: &ComponentNode, tree: &TreeView) -> Option<RectF> {
486
+ let mut frame = node.frame;
487
+ let Some(config) = node.parent else {
488
+ return Some(frame);
489
+ };
490
+ let container_width = tree
491
+ .node(config)
492
+ .map(|config| config.frame.w)
493
+ .unwrap_or(frame.w);
494
+ let tallest = tree
495
+ .children(node.tag)
496
+ .into_iter()
497
+ .filter_map(|tag| tree.node(tag))
498
+ .fold(0.0f32, |height, child| height.max(child.frame.h));
499
+ frame.x = match props::subview_type(node) {
500
+ "right" => container_width - frame.w - 12.0,
501
+ "center" | "title" => (container_width - frame.w) / 2.0,
502
+ "left" => 12.0,
503
+ _ => frame.x,
504
+ };
505
+ frame.y = ((HEADER_HEIGHT - tallest).max(0.0)) / 2.0;
506
+ Some(frame)
507
+ }
508
+ }
509
+
510
+ /// A screen's content area, inset by the header when one is showing.
511
+ pub struct ContentWrapperRenderer;
512
+
513
+ impl ComponentRenderer for ContentWrapperRenderer {
514
+ fn render(
515
+ &self,
516
+ _node: &ComponentNode,
517
+ _window: &mut Window,
518
+ _cx: &mut App,
519
+ ) -> Option<AnyElement> {
520
+ None
521
+ }
522
+
523
+ fn layout_frame(&self, node: &ComponentNode, tree: &TreeView) -> Option<RectF> {
524
+ let Some(screen) = node.parent else {
525
+ return None;
526
+ };
527
+ let mut frame = node.frame;
528
+ // Only a header that is actually showing takes space. When it is hidden
529
+ // the C++ half already gave the wrapper the whole screen, so the Yoga
530
+ // frame is the answer and any adjustment here would move it twice.
531
+ if !header_hidden(screen, tree) {
532
+ frame.y += HEADER_HEIGHT;
533
+ frame.h = (frame.h - HEADER_HEIGHT).max(0.0);
534
+ }
535
+ Some(frame)
536
+ }
537
+ }
538
+
539
+ /// Whether a screen's header config is hidden, whether or not it exists.
540
+ fn header_hidden(screen: i32, tree: &TreeView) -> bool {
541
+ tree.children(screen)
542
+ .into_iter()
543
+ .filter_map(|tag| tree.node(tag))
544
+ .find(|node| node.component == HEADER_CONFIG)
545
+ .is_some_and(|config| props::header_hidden(&config))
546
+ }
547
+
548
+ /// The gap a modal sheet leaves around itself, and the size it is capped at.
549
+ const SHEET_MARGIN: f32 = 48.0;
550
+ const SHEET_MAX_WIDTH: f32 = 760.0;
551
+ const SHEET_MAX_HEIGHT: f32 = 640.0;
552
+
553
+ /// A modal presentation. It paints like a screen; the difference is that the
554
+ /// card underneath stays visible.
555
+ pub struct ModalScreenRenderer;
556
+
557
+ impl ComponentRenderer for ModalScreenRenderer {
558
+ fn render(
559
+ &self,
560
+ _node: &ComponentNode,
561
+ _window: &mut Window,
562
+ _cx: &mut App,
563
+ ) -> Option<AnyElement> {
564
+ None
565
+ }
566
+ }
567
+
568
+ /// A full-window overlay paints over the whole window, not inside the tree.
569
+ pub struct FullWindowOverlayRenderer;
570
+
571
+ impl ComponentRenderer for FullWindowOverlayRenderer {
572
+ fn render(
573
+ &self,
574
+ _node: &ComponentNode,
575
+ _window: &mut Window,
576
+ _cx: &mut App,
577
+ ) -> Option<AnyElement> {
578
+ None
579
+ }
580
+
581
+ fn portal(&self, _props: &serde_json::Value) -> Option<Portal> {
582
+ Some(Portal {
583
+ visible: true,
584
+ close_request_event: None,
585
+ })
586
+ }
587
+ }
588
+
589
+ /// Every component name this module paints, and how to build its renderer.
590
+ pub const RENDERED_COMPONENTS: &[(&str, fn() -> Box<dyn ComponentRenderer>)] = &[
591
+ (STACK, || Box::new(StackRenderer)),
592
+ (SCREEN, || Box::new(ScreenRenderer)),
593
+ (MODAL_SCREEN, || Box::new(ModalScreenRenderer)),
594
+ (HEADER_CONFIG, || Box::new(HeaderConfigRenderer)),
595
+ (HEADER_SUBVIEW, || Box::new(HeaderSubviewRenderer)),
596
+ (CONTENT_WRAPPER, || Box::new(ContentWrapperRenderer)),
597
+ (FULL_WINDOW_OVERLAY, || Box::new(FullWindowOverlayRenderer)),
598
+ ];
599
+
600
+ #[cfg(test)]
601
+ mod tests {
602
+ use super::*;
603
+ use crate::state;
604
+ use std::sync::Arc;
605
+
606
+ /// Builds a node with the props this module's C++ half would have produced.
607
+ fn node(component: &str, tag: i32, props: serde_json::Value) -> ComponentNode {
608
+ ComponentNode {
609
+ component: component.to_string(),
610
+ tag,
611
+ frame: RectF::default(),
612
+ scale: (1.0, 1.0),
613
+ props: Arc::new(props),
614
+ parent: None,
615
+ children: Vec::new(),
616
+ }
617
+ }
618
+
619
+ fn frame(x: f32, y: f32, w: f32, h: f32) -> RectF {
620
+ RectF { x, y, w, h }
621
+ }
622
+
623
+ /// A tree built from plain values, matching what core would hand a hook.
624
+ fn tree(
625
+ nodes: Vec<react_native_gpui::extensions::TestNode>,
626
+ edges: &[(i32, i32)],
627
+ top_inset: f32,
628
+ ) -> TreeView<'static> {
629
+ TreeView::for_test(nodes, edges, top_inset)
630
+ }
631
+
632
+ fn entry(
633
+ tag: i32,
634
+ component: &'static str,
635
+ frame: RectF,
636
+ props: serde_json::Value,
637
+ ) -> react_native_gpui::extensions::TestNode {
638
+ react_native_gpui::extensions::TestNode {
639
+ tag,
640
+ component: component.to_string(),
641
+ frame,
642
+ scale: (1.0, 1.0),
643
+ props,
644
+ }
645
+ }
646
+
647
+ /// A stack with one screen, its header config and its content wrapper.
648
+ ///
649
+ /// `header` is the header config's props and `content_height` the wrapper's
650
+ /// Yoga height, which the C++ half computes for the whole screen.
651
+ fn stack_with_header(
652
+ header: serde_json::Value,
653
+ content_height: f32,
654
+ ) -> (TreeView<'static>, i32, i32) {
655
+ let nodes = vec![
656
+ entry(
657
+ 1,
658
+ STACK,
659
+ frame(0.0, 0.0, 400.0, 600.0),
660
+ serde_json::json!({}),
661
+ ),
662
+ entry(
663
+ 2,
664
+ SCREEN,
665
+ frame(0.0, 0.0, 400.0, 600.0),
666
+ serde_json::json!({"activityState": 2, "stackPresentation": "card"}),
667
+ ),
668
+ entry(3, HEADER_CONFIG, frame(0.0, 0.0, 400.0, 56.0), header),
669
+ entry(
670
+ 4,
671
+ CONTENT_WRAPPER,
672
+ frame(0.0, 0.0, 400.0, content_height),
673
+ serde_json::json!({}),
674
+ ),
675
+ ];
676
+ let edges = [(1, 2), (2, 3), (2, 4)];
677
+ (tree(nodes, &edges, 0.0), 3, 4)
678
+ }
679
+
680
+ #[test]
681
+ fn a_visible_header_pushes_the_content_down_by_its_height() {
682
+ // Yoga gives the wrapper the whole screen; a visible header takes the
683
+ // top 56 px of it, so the content starts below the bar.
684
+ let (tree, _, wrapper) = stack_with_header(serde_json::json!({"title": "Home"}), 600.0);
685
+ let node = tree.node(wrapper).expect("wrapper is mounted");
686
+ let laid_out = ContentWrapperRenderer
687
+ .layout_frame(&node, &tree)
688
+ .expect("the wrapper is laid out");
689
+ assert_eq!(laid_out.y, HEADER_HEIGHT);
690
+ assert_eq!(laid_out.h, 600.0 - HEADER_HEIGHT);
691
+ }
692
+
693
+ #[test]
694
+ fn a_hidden_header_leaves_the_content_where_yoga_put_it() {
695
+ // The C++ half gives a hidden-header screen's wrapper the full height,
696
+ // so any adjustment here would move the content a second time: the
697
+ // reported bug was content sitting 56 px above the top of the window.
698
+ let (tree, _, wrapper) =
699
+ stack_with_header(serde_json::json!({"title": "Home", "hidden": true}), 600.0);
700
+ let node = tree.node(wrapper).expect("wrapper is mounted");
701
+ let laid_out = ContentWrapperRenderer
702
+ .layout_frame(&node, &tree)
703
+ .expect("the wrapper is laid out");
704
+ assert_eq!(laid_out.y, 0.0);
705
+ assert_eq!(laid_out.h, 600.0);
706
+ }
707
+
708
+ #[test]
709
+ fn the_header_sits_below_the_window_inset_and_is_one_bar_tall() {
710
+ let (tree, header, _) = stack_with_header(serde_json::json!({"title": "Home"}), 600.0);
711
+ // A window whose title bar takes 28 px: the bar must start below it.
712
+ let tree = tree_with_inset(&tree, 28.0);
713
+ let node = tree.node(header).expect("header is mounted");
714
+ let laid_out = HeaderConfigRenderer
715
+ .layout_frame(&node, &tree)
716
+ .expect("the header is laid out");
717
+ assert_eq!(laid_out.y, 28.0);
718
+ assert_eq!(laid_out.h, HEADER_HEIGHT);
719
+ }
720
+
721
+ #[test]
722
+ fn a_hidden_header_takes_no_height_and_paints_nothing() {
723
+ let (tree, header, _) =
724
+ stack_with_header(serde_json::json!({"title": "Home", "hidden": true}), 600.0);
725
+ let node = tree.node(header).expect("header is mounted");
726
+ let laid_out = HeaderConfigRenderer
727
+ .layout_frame(&node, &tree)
728
+ .expect("the header is laid out");
729
+ assert_eq!(laid_out.h, 0.0);
730
+ assert!(HeaderConfigRenderer.hides_subtree(&node, &tree));
731
+ assert_eq!(
732
+ HeaderConfigRenderer.decorate(&node, &tree).display,
733
+ Some(false)
734
+ );
735
+ }
736
+
737
+ /// A header carrying a `title` or `center` subview must not paint a second
738
+ /// title of its own: the subview is the title.
739
+ #[test]
740
+ fn a_title_subview_suppresses_the_headers_own_title() {
741
+ for subview_type in ["title", "center"] {
742
+ let nodes = vec![
743
+ entry(
744
+ 1,
745
+ STACK,
746
+ frame(0.0, 0.0, 400.0, 600.0),
747
+ serde_json::json!({}),
748
+ ),
749
+ entry(
750
+ 2,
751
+ SCREEN,
752
+ frame(0.0, 0.0, 400.0, 600.0),
753
+ serde_json::json!({"activityState": 2, "stackPresentation": "card"}),
754
+ ),
755
+ entry(
756
+ 3,
757
+ HEADER_CONFIG,
758
+ frame(0.0, 0.0, 400.0, 56.0),
759
+ serde_json::json!({"title": "Details 1"}),
760
+ ),
761
+ entry(
762
+ 4,
763
+ HEADER_SUBVIEW,
764
+ frame(0.0, 0.0, 80.0, 20.0),
765
+ serde_json::json!({ "type": subview_type }),
766
+ ),
767
+ ];
768
+ let edges = [(1, 2), (2, 3), (3, 4)];
769
+ let tree = tree(nodes, &edges, 0.0);
770
+ let config = tree.node(3).expect("header config is mounted");
771
+ assert!(
772
+ has_title_subview(&config, &tree),
773
+ "a {subview_type} subview paints the title"
774
+ );
775
+ }
776
+ }
777
+
778
+ #[test]
779
+ fn a_header_without_a_title_subview_paints_its_title() {
780
+ let (tree, header, _) = stack_with_header(serde_json::json!({"title": "Home"}), 600.0);
781
+ let node = tree.node(header).expect("header is mounted");
782
+ assert!(!has_title_subview(&node, &tree));
783
+ }
784
+
785
+ #[test]
786
+ fn the_header_paints_the_colours_react_native_asked_for() {
787
+ let (tree, header, _) = stack_with_header(
788
+ serde_json::json!({
789
+ "title": "Home",
790
+ "backgroundColor": 0xff203040u32,
791
+ }),
792
+ 600.0,
793
+ );
794
+ let node = tree.node(header).expect("header is mounted");
795
+ let decoration = HeaderConfigRenderer.decorate(&node, &tree);
796
+ assert_eq!(decoration.background, Some(0xff203040));
797
+ assert_ne!(decoration.display, Some(false));
798
+ }
799
+
800
+ #[test]
801
+ fn a_header_without_a_background_falls_back_to_the_default() {
802
+ let (tree, header, _) = stack_with_header(serde_json::json!({"title": "Home"}), 600.0);
803
+ let node = tree.node(header).expect("header is mounted");
804
+ assert_eq!(
805
+ HeaderConfigRenderer.decorate(&node, &tree).background,
806
+ Some(0xff171923)
807
+ );
808
+ }
809
+
810
+ /// The same tree as `stack_with_header` but with a window inset.
811
+ fn tree_with_inset(source: &TreeView<'_>, inset: f32) -> TreeView<'static> {
812
+ let mut nodes: Vec<react_native_gpui::extensions::TestNode> = Vec::new();
813
+ let mut edges = Vec::new();
814
+ for tag in [1, 2, 3, 4] {
815
+ let node = source.node(tag).expect("node is mounted");
816
+ nodes.push(react_native_gpui::extensions::TestNode {
817
+ tag,
818
+ component: node.component,
819
+ frame: node.frame,
820
+ scale: node.scale,
821
+ props: (*node.props).clone(),
822
+ });
823
+ if let Some(parent) = node.parent {
824
+ edges.push((parent, tag));
825
+ }
826
+ }
827
+ tree(nodes, &edges, inset)
828
+ }
829
+ }