hydrolysis 0.3.1

GPU-required self-drawn backend for WaterUI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Virtual-scroll / lazy-loading performance regression harness.
//!
//! Mirrors the `water preview perf` budget methodology (frame-economy counters:
//! rebuilt frames and measurement-cache misses) as a deterministic in-crate test.
//! The guarantee under test is the defining property of working virtualization:
//! the cost of building and scrolling a lazy list is bounded by the number of
//! *visible* rows, **independent of the total row count**. A regression that
//! materializes or re-measures the whole collection would make per-frame work
//! scale with the total — which these tests fail on.

use core::time::Duration;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;

use waterui_core::handler::AnyViewBuilder;
use waterui_core::id::SelfId;
use waterui_core::layout::Point;
use waterui_core::{AnyView, Dynamic};
use waterui_layout::scroll::{ScrollController, scroll};
use waterui_layout::stack::{VStack, vstack};

#[cfg(feature = "accessibility")]
use waterui::Binding;
use waterui::Str;
use waterui::ViewExt as _;
use waterui::component::list::{List, ListItem};
use waterui::component::text;
use waterui::prelude::{FlowAnimationPreset, flow_markdown};

use super::{MinimalTestTheme, test_environment};
use crate::HeadlessRuntime;
use crate::platform::InputEvent;

const WINDOW_WIDTH: u32 = 400;
const WINDOW_HEIGHT: u32 = 640;
const ROW_HEIGHT: f32 = 44.0;
const SCROLL_FRAMES: u32 = 24;
/// Frames to run an indexed jump for. The approach eases with a ~180ms time
/// constant, so this is comfortably past the point it snaps to its target.
const FRAMES_TO_SETTLE_JUMP: u32 = 60;

/// A vertically-scrolling lazy list of `rows` fixed-height rows.
fn lazy_list(rows: usize) -> AnyView {
    let data = (0..rows).map(SelfId::new).collect::<Vec<_>>();
    AnyView::new(scroll(VStack::for_each(data, |_| {
        ().size(360.0, ROW_HEIGHT)
    })))
}

fn runtime_for(rows: usize) -> HeadlessRuntime {
    let builder = AnyViewBuilder::<AnyView>::new(move || lazy_list(rows));
    let env = test_environment();
    HeadlessRuntime::new_for_tests(
        env,
        builder,
        WINDOW_WIDTH,
        WINDOW_HEIGHT,
        MinimalTestTheme::default(),
    )
}

/// Frame-economy totals over an initial build plus a fixed scroll sequence.
/// Both are re-measurement counts (`measurement_cache_misses`): the per-frame
/// work the visible window actually performed.
#[derive(Debug, Clone, Copy)]
struct ScrollCost {
    initial_misses: u32,
    scroll_misses: u32,
}

fn measure_scroll_cost(rows: usize) -> ScrollCost {
    let mut runtime = runtime_for(rows);
    let start = Instant::now();
    let initial = runtime.pump_at(false, start);
    let initial_misses = initial.profile.counters.measurement_cache_misses;

    let mut scroll_misses = 0;
    for frame in 1..=SCROLL_FRAMES {
        runtime.push_input_event(InputEvent::Scroll {
            x: (WINDOW_WIDTH / 2) as f32,
            y: (WINDOW_HEIGHT / 2) as f32,
            dx: 0.0,
            dy: -200.0,
            is_line_delta: false,
        });
        let result = runtime.pump_at(false, start + Duration::from_millis(u64::from(frame) * 16));
        scroll_misses += result.profile.counters.measurement_cache_misses;
    }

    ScrollCost {
        initial_misses,
        scroll_misses,
    }
}

#[test]
fn lazy_scroll_cost_is_independent_of_total_rows() {
    let small = measure_scroll_cost(500);
    let large = measure_scroll_cost(20_000);
    eprintln!("perf_scroll small(500)={small:?}");
    eprintln!("perf_scroll large(20000)={large:?}");

    // 40x more rows must not blow up per-frame work: virtualization bounds both
    // the initial build and each scroll frame to the visible window.
    assert!(
        large.initial_misses <= small.initial_misses + 64,
        "initial build cost scaled with total rows (virtualization regression): {small:?} vs {large:?}"
    );
    assert!(
        large.scroll_misses <= small.scroll_misses * 2 + 64,
        "scroll cost scaled with total rows (virtualization regression): {small:?} vs {large:?}"
    );
}

#[test]
fn coordinate_jump_over_lazy_stack_materializes_only_the_target_window() {
    const ROWS: usize = 100_000;
    const TARGET_ROW: usize = 90_000;

    let materialized = Arc::new(AtomicUsize::new(0));
    let controller = ScrollController::new(Point::zero());
    let builder = {
        let materialized = Arc::clone(&materialized);
        let controller = controller.clone();
        AnyViewBuilder::<AnyView>::new(move || {
            let rows = (0..ROWS).map(SelfId::new).collect::<Vec<_>>();
            let materialized = Arc::clone(&materialized);
            AnyView::new(
                scroll(VStack::for_each(rows, move |_| {
                    materialized.fetch_add(1, Ordering::Relaxed);
                    ().size(360.0, ROW_HEIGHT)
                }))
                .scroll_controller(&controller),
            )
        })
    };
    let env = test_environment();
    let mut runtime = HeadlessRuntime::new_for_tests(
        env,
        builder,
        WINDOW_WIDTH,
        WINDOW_HEIGHT,
        MinimalTestTheme::default(),
    );
    let start = Instant::now();
    let _ = runtime.pump_at(false, start);
    let initial_materialized = materialized.load(Ordering::Relaxed);

    controller.scroll_to(Point::new(0.0, TARGET_ROW as f32 * ROW_HEIGHT));
    let _ = runtime.pump_at(false, start + Duration::from_millis(16));
    let jump_materialized = materialized.load(Ordering::Relaxed) - initial_materialized;

    assert!(
        initial_materialized < 64,
        "initial lazy viewport materialized {initial_materialized} rows"
    );
    assert!(
        jump_materialized < 64,
        "deep coordinate jump materialized {jump_materialized} rows instead of one viewport"
    );
}

#[test]
fn indexed_list_jump_materializes_only_the_target_window() {
    const ROWS: usize = 100_000;
    const TARGET_ROW: usize = 90_000;

    let materialized = Arc::new(AtomicUsize::new(0));
    let controller = ScrollController::new(0usize);
    let builder = {
        let materialized = Arc::clone(&materialized);
        let controller = controller.clone();
        AnyViewBuilder::<AnyView>::new(move || {
            let rows = (0..ROWS).map(SelfId::new).collect::<Vec<_>>();
            let materialized = Arc::clone(&materialized);
            AnyView::new(
                List::for_each(rows, move |row| {
                    let index = row.into_inner();
                    materialized.fetch_add(1, Ordering::Relaxed);
                    let label = if index == TARGET_ROW {
                        "Target row"
                    } else {
                        "Dataset row"
                    };
                    ListItem::new(vstack((text(label),)).size(360.0, ROW_HEIGHT))
                })
                .scroll_controller(&controller),
            )
        })
    };
    let env = test_environment();
    let mut runtime = HeadlessRuntime::new_for_tests(
        env,
        builder,
        WINDOW_WIDTH,
        WINDOW_HEIGHT,
        MinimalTestTheme::default(),
    );
    let start = Instant::now();
    let _ = runtime.pump_at(false, start);
    let initial_materialized = materialized.load(Ordering::Relaxed);

    // An indexed jump eases into place instead of teleporting, so the target
    // arrives over several frames rather than on the next one. Only the final
    // approach is animated — a distant target is closed instantly first — so
    // the whole jump still costs a bounded number of viewports, which is what
    // this test is really guarding.
    controller.scroll_to(TARGET_ROW);
    #[cfg_attr(
        not(feature = "accessibility"),
        allow(unused_mut, unused_variables, reason = "labels need the a11y tree")
    )]
    let mut saw_target = false;
    let mut elapsed = Duration::ZERO;
    let mut previous = initial_materialized;
    let mut worst_frame = 0usize;
    for _ in 0..FRAMES_TO_SETTLE_JUMP {
        elapsed += Duration::from_millis(16);
        let frame = runtime.pump_at(false, start + elapsed);
        let total = materialized.load(Ordering::Relaxed);
        worst_frame = worst_frame.max(total - previous);
        previous = total;
        #[cfg(feature = "accessibility")]
        {
            saw_target |= frame
                .tree_update
                .into_iter()
                .flat_map(|update| update.nodes)
                .any(|(_, node)| node.label() == Some("Target row"));
        }
        #[cfg(not(feature = "accessibility"))]
        let _ = frame;
    }

    assert!(
        initial_materialized < 128,
        "initial List viewport materialized {initial_materialized} rows"
    );
    // The jump eases in, so cost is spread over frames rather than landing on
    // one. What must hold is that no single frame ever materializes more than a
    // viewport: that is what proves the glide is not dragging the list through
    // the 90,000 rows between here and the target.
    assert!(
        worst_frame < 128,
        "a frame of the indexed List jump materialized {worst_frame} rows instead of one viewport"
    );
    #[cfg(feature = "accessibility")]
    assert!(
        saw_target,
        "the indexed target row must intersect the viewport once the jump settles"
    );
}

#[test]
fn visible_lazy_item_remeasures_connected_dynamic_through_retained_node() {
    let (handler, dynamic) = Dynamic::new();
    handler.set(text("initial"));
    let builder = AnyViewBuilder::<AnyView>::new(move || {
        let dynamic = dynamic.clone();
        AnyView::new(scroll(VStack::for_each(
            vec![SelfId::new(0_u64)],
            move |_| dynamic.clone(),
        )))
    });
    let env = test_environment();
    let mut runtime = HeadlessRuntime::new_for_tests(
        env,
        builder,
        WINDOW_WIDTH,
        WINDOW_HEIGHT,
        MinimalTestTheme::default(),
    );
    let start = Instant::now();
    let _ = runtime.pump_at(false, start);

    handler.set(text("updated dynamic row"));
    let updated = runtime.pump_at(false, start + Duration::from_millis(16));

    assert_eq!(
        updated.profile.counters.rebuild_iterations, 0,
        "a connected Dynamic inside a visible lazy item must patch and remeasure through \
         its retained node without rebuilding the window"
    );
    #[cfg(feature = "accessibility")]
    assert!(
        updated
            .tree_update
            .into_iter()
            .flat_map(|update| update.nodes)
            .any(|(_, node)| node.label() == Some("updated dynamic row")),
        "the patched lazy item must lay out and publish its replacement subtree"
    );
}

#[test]
fn flow_markdown_blocks_reconnect_after_lazy_eviction() {
    let markdown = (0..64)
        .map(|index| {
            format!(
                "# Section {index}\n\nParagraph {index} keeps the document tall enough to recycle blocks.\n\n"
            )
        })
        .collect::<String>();
    let controller = ScrollController::new(Point::zero());
    let builder = {
        let controller = controller.clone();
        AnyViewBuilder::<AnyView>::new(move || {
            AnyView::new(
                scroll(
                    flow_markdown(Str::from(markdown.clone())).preset(FlowAnimationPreset::None),
                )
                .scroll_controller(&controller),
            )
        })
    };
    let env = test_environment();
    let mut runtime = HeadlessRuntime::new_for_tests(
        env,
        builder,
        WINDOW_WIDTH,
        WINDOW_HEIGHT,
        MinimalTestTheme::default(),
    );
    let start = Instant::now();
    let _ = runtime.pump_at(false, start);

    controller.scroll_to(Point::new(0.0, 3_000.0));
    let _ = runtime.pump_at(false, start + Duration::from_millis(16));
    controller.scroll_to(Point::zero());
    let returned = runtime.pump_at(false, start + Duration::from_millis(32));
    #[cfg(not(feature = "accessibility"))]
    let _ = returned;

    #[cfg(feature = "accessibility")]
    assert!(
        returned
            .tree_update
            .into_iter()
            .flat_map(|update| update.nodes)
            .any(|(_, node)| node.label() == Some("Section 0")),
        "a FlowMarkdown block rematerialized after lazy eviction must reconnect with fresh retained content"
    );
}

#[cfg(feature = "accessibility")]
#[test]
fn flow_markdown_append_preserves_user_scroll_offset() {
    fn scroll_y(result: crate::HeadlessPumpResult) -> f64 {
        result
            .tree_update
            .expect("FlowMarkdown pump must publish an accessibility update")
            .nodes
            .into_iter()
            .find_map(|(_, node)| node.scroll_y())
            .expect("FlowMarkdown scroll view must publish its vertical offset")
    }

    let initial = (0..64)
        .map(|index| {
            format!(
                "# Section {index}\n\nParagraph {index} keeps the document tall enough to retain a user offset.\n\n"
            )
        })
        .collect::<String>();
    let source = Binding::container(Str::from(initial.clone()));
    let builder = {
        let source = source.clone();
        AnyViewBuilder::<AnyView>::new(move || {
            AnyView::new(scroll(
                flow_markdown(source.clone()).preset(FlowAnimationPreset::None),
            ))
        })
    };
    let env = test_environment();
    let mut runtime = HeadlessRuntime::new_for_tests(
        env,
        builder,
        WINDOW_WIDTH,
        WINDOW_HEIGHT,
        MinimalTestTheme::default(),
    );
    let start = Instant::now();
    let _ = runtime.pump_at(false, start);

    runtime.push_input_event(InputEvent::Scroll {
        x: WINDOW_WIDTH as f32 / 2.0,
        y: WINDOW_HEIGHT as f32 / 2.0,
        dx: 0.0,
        dy: -120.0,
        is_line_delta: false,
    });
    let before_append = scroll_y(runtime.pump_at(false, start + Duration::from_millis(16)));
    assert!(
        before_append > 0.0,
        "the physical scroll input must establish a non-zero user offset"
    );

    source.set(Str::from(format!("{initial}\n\nAppended tail block.")));
    let after_append = scroll_y(runtime.pump_at(false, start + Duration::from_millis(32)));
    assert_eq!(
        before_append, after_append,
        "incremental FlowMarkdown growth must not reset the user's scroll offset"
    );
}