@react-native-gpui/screens 0.0.0-stage → 4.28.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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/desktop/CMakeLists.txt +30 -0
- package/desktop/Cargo.lock +8126 -0
- package/desktop/Cargo.toml +54 -0
- package/desktop/build.rs +38 -0
- package/desktop/cpp/GpuiScreensModule.cpp +77 -0
- package/desktop/src/lib.rs +29 -0
- package/desktop/src/lifecycle.rs +80 -0
- package/desktop/src/props.rs +177 -0
- package/desktop/src/render.rs +829 -0
- package/desktop/src/stack.rs +396 -0
- package/desktop/src/state.rs +162 -0
- package/desktop/src/transition.rs +193 -0
- package/desktop/src/unsupported.rs +39 -0
- package/package.json +31 -4
- package/react-native.config.js +36 -0
- package/src/FullWindowOverlay.desktop.tsx +19 -0
- package/src/core.desktop.ts +28 -0
- package/src/customDirectEventTypes.desktop.ts +16 -0
|
@@ -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
|
+
}
|