diff --git a/packages/core/test/fixtures/gea-elements-virtual-list/App.tsx b/packages/core/test/fixtures/gea-elements-virtual-list/App.tsx new file mode 100644 index 0000000..8e16996 --- /dev/null +++ b/packages/core/test/fixtures/gea-elements-virtual-list/App.tsx @@ -0,0 +1,12 @@ +import { Component } from '@geastack/core' +import { Rows } from './Rows' + +export class App extends Component { + template() { + return ( +
+ +
+ ) + } +} diff --git a/packages/core/test/fixtures/gea-elements-virtual-list/Rows.tsx b/packages/core/test/fixtures/gea-elements-virtual-list/Rows.tsx new file mode 100644 index 0000000..fc7a794 --- /dev/null +++ b/packages/core/test/fixtures/gea-elements-virtual-list/Rows.tsx @@ -0,0 +1,27 @@ +import { Store, type VirtualListElement } from '@geastack/core' +import { VirtualList } from '@geastack/elements' + +// The @geastack/elements VirtualList used the way its README shows: the app +// keeps scrollTop in a Store and copies the element's offset back on scroll. +class RowsStore extends Store { + scrollTop = 0 +} + +const rows = new RowsStore() + +export function Rows() { + return ( + {'Row ' + index}} + onScroll={() => { + const list = document.getElementById('rows') as VirtualListElement | null + if (list) rows.scrollTop = list.scrollTop + }} + /> + ) +} diff --git a/packages/core/test/fixtures/gea-elements-virtual-list/index.tsx b/packages/core/test/fixtures/gea-elements-virtual-list/index.tsx new file mode 100644 index 0000000..b83d2c3 --- /dev/null +++ b/packages/core/test/fixtures/gea-elements-virtual-list/index.tsx @@ -0,0 +1,5 @@ +import { mount } from '@geastack/core' +import { App } from './App' +import './styles.css' + +mount(App) diff --git a/packages/core/test/fixtures/gea-elements-virtual-list/styles.css b/packages/core/test/fixtures/gea-elements-virtual-list/styles.css new file mode 100644 index 0000000..b1d09d7 --- /dev/null +++ b/packages/core/test/fixtures/gea-elements-virtual-list/styles.css @@ -0,0 +1,13 @@ +.app { + width: 100vw; + height: 100vh; +} + +.rows { + width: 100vw; + height: 400px; +} + +.row-label { + font-size: 16px; +} diff --git a/packages/core/test/run-gea-elements-virtual-list-pipeline.sh b/packages/core/test/run-gea-elements-virtual-list-pipeline.sh new file mode 100755 index 0000000..06522da --- /dev/null +++ b/packages/core/test/run-gea-elements-virtual-list-pipeline.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +exec "$ROOT/packages/core/test/run-gea-native-app-pipeline.sh" \ + "gea-elements-virtual-list-pipeline" \ + "$ROOT/packages/core/test/fixtures/gea-elements-virtual-list" \ + "index.tsx" \ + "packages/core/test/test_gea_elements_virtual_list_main.cpp" diff --git a/packages/core/test/test_gea_elements_virtual_list_main.cpp b/packages/core/test/test_gea_elements_virtual_list_main.cpp new file mode 100644 index 0000000..bca7244 --- /dev/null +++ b/packages/core/test/test_gea_elements_virtual_list_main.cpp @@ -0,0 +1,95 @@ +#include "native_test_harness.h" + +#include "app.h" +#include "ui/tree_internal.h" + +#include +#include +#include +#include + +extern void __gea_top_level(); + +namespace { +constexpr int kViewportWidth = 720; +constexpr int kViewportHeight = 1440; +constexpr double kDevicePixelRatio = 2.0; +constexpr const char *kTest = "test_gea_elements_virtual_list_main"; +// VirtualList renders a fixed pool of SLOT_COUNT (VirtualList.tsx) slots. +constexpr int kSlotCount = 16; + +// The row indexes the visible labels show. nodesWithClass skips hidden nodes, +// so slots past the end of the list are not counted. +std::vector visibleRowIndexes() +{ + std::vector out; + for (const int id : gea::embedded::test::nodesWithClass("row-label")) { + const std::string text = gea::embedded::test::textContent(id); + if (text.rfind("Row ", 0) != 0) continue; + out.push_back(std::atoi(text.c_str() + 4)); + } + return out; +} + +// Every slot shows exactly one row, and together they show rows +// first..first+15 with no gap or repeat. +bool expectRows(const char *phase, int first) +{ + const auto indexes = visibleRowIndexes(); + std::vector seen(kSlotCount, false); + bool ok = indexes.size() == static_cast(kSlotCount); + for (const int index : indexes) { + const int offset = index - first; + if (offset < 0 || offset >= kSlotCount || seen[offset]) ok = false; + else seen[offset] = true; + } + if (!ok) { + std::fprintf(stderr, "[%s] %s: expected rows %d..%d once each, got %zu labels:", kTest, phase, first, first + kSlotCount - 1, indexes.size()); + for (const int index : indexes) std::fprintf(stderr, " %d", index); + std::fprintf(stderr, "\n"); + } + return ok; +} +} // namespace + +int main() +{ + using namespace gea::embedded::test; + auto &tree = gea::embedded::ui::Tree::instance(); + + resetNativeHost(); + setNativeDisplaySize(kViewportWidth, kViewportHeight); + gea::framework::app::Application::init(kViewportWidth, kViewportHeight, kDevicePixelRatio); + __gea_top_level(); + refresh(); + gea::framework::app::Application::frame(16); + + const auto lists = nodesWithType(gea::embedded::ui::NodeType::VirtualList); + if (lists.size() != 1) { + std::fprintf(stderr, "[%s] expected one virtual-list node, got %zu\n", kTest, lists.size()); + dumpTree(kTest); + return 1; + } + const auto &list = tree.node(lists[0]); + + // The rows are absolutely positioned, so the list's height comes only from + // the class the app passes. + const int expectedHeight = static_cast(400 * kDevicePixelRatio); + bool ok = true; + if (list.layout.height != expectedHeight) { + std::fprintf(stderr, "[%s] list height %d, expected %d from .rows\n", kTest, list.layout.height, expectedHeight); + ok = false; + } + + // The pool shows the first 16 rows, each from itemTemplate. + ok = ok && expectRows("initial", 0); + + // Scrolling is not checked here: a scrollTop change re-renders VirtualList + // and replaces its node, which resets the native offset. + + if (!ok) { + dumpTree(kTest); + return 1; + } + return 0; +} diff --git a/packages/elements/components/VirtualList.d.ts b/packages/elements/components/VirtualList.d.ts index 814c041..558436e 100644 --- a/packages/elements/components/VirtualList.d.ts +++ b/packages/elements/components/VirtualList.d.ts @@ -1,6 +1,7 @@ -import type { ClassValue, Event, TouchEventHandler } from '@geastack/core' +import type { ClassValue, Event, GeaJsxElement, TouchEventHandler } from '@geastack/core' -export type VirtualListItemTemplate = (index: number, top: number) => any +/** Renders row `index`, placed at `top`. Return an element; wrap plain text in one, such as a ``. */ +export type VirtualListItemTemplate = (index: number, top: number) => GeaJsxElement export interface VirtualListProps { itemCount: number diff --git a/packages/elements/components/VirtualList.tsx b/packages/elements/components/VirtualList.tsx index 1ca393d..d701dda 100644 --- a/packages/elements/components/VirtualList.tsx +++ b/packages/elements/components/VirtualList.tsx @@ -1,4 +1,4 @@ -import type { ClassValue, Event, TouchEventHandler } from '@geastack/core' +import type { ClassValue, Event, GeaJsxElement, TouchEventHandler } from '@geastack/core' // A windowing list backed by the native element. The element is // an ordinary overflow:scroll container that scrolls over a virtual content @@ -10,7 +10,7 @@ import type { ClassValue, Event, TouchEventHandler } from '@geastack/core' // blit instead of re-rendering. Row content, height, colours and fonts all come // from `itemTemplate` + its CSS; the framework owns none of it. -export type VirtualListItemTemplate = (index: number, top: number) => any +export type VirtualListItemTemplate = (index: number, top: number) => GeaJsxElement export interface VirtualListProps { itemCount: number diff --git a/packages/elements/components/index.ts b/packages/elements/components/index.ts index 992a63c..705bad0 100644 --- a/packages/elements/components/index.ts +++ b/packages/elements/components/index.ts @@ -1,2 +1,3 @@ export { CameraView } from './CameraView' export { default as VirtualList } from './VirtualList' +export type { VirtualListProps, VirtualListItemTemplate } from './VirtualList'