diff --git a/packages/core/test/run-gea-progress-bar-pipeline.sh b/packages/core/test/run-gea-progress-bar-pipeline.sh new file mode 100755 index 0000000..189c44c --- /dev/null +++ b/packages/core/test/run-gea-progress-bar-pipeline.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +BUILD_DIR="$ROOT/packages/core/test/.build/gea-progress-bar-pipeline" +CXX_BIN="${CXX:-clang++}" + +source "$ROOT/packages/core/test/native-test-common.sh" +gea_require_app_project + +mkdir -p "$BUILD_DIR" + +node "$ROOT/packages/core/scripts/build-gea-vite-geatsc.mjs" \ + --app-dir "$GEA_APP_PROJECT/apps/progress-bar" \ + --entry "index.tsx" \ + --out-dir "$BUILD_DIR" \ + --gea-embedded-compat \ + --gea-ir-backend \ + --pixel-panel-endian 1 \ + --allow-any + +gea_build_native_test \ + "$BUILD_DIR" \ + "$BUILD_DIR/gea-progress-bar-test" \ + "$ROOT/packages/core/test/test_gea_progress_bar_main.cpp" + +"$BUILD_DIR/gea-progress-bar-test" diff --git a/packages/core/test/test_gea_progress_bar_main.cpp b/packages/core/test/test_gea_progress_bar_main.cpp new file mode 100644 index 0000000..f7d389b --- /dev/null +++ b/packages/core/test/test_gea_progress_bar_main.cpp @@ -0,0 +1,408 @@ +#include "native_test_harness.h" + +#include "app.h" +#include "events.h" +#include "ui/document.h" +#include "ui/tree_internal.h" + +#include +#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_progress_bar_main"; +// The display's own pixel format: 16-bit RGB565 by default, wider or narrower +// on builds that change it. +using Pixel = gea::framework::graphics::pixel::native_t; + +double g_nowMs = 0; + +// core/runtime.cpp starts CSS animation tracks added by later class changes on +// every frame; pumpFrame does not, so do it here the way the device does. +void advance(double ms) +{ + g_nowMs += ms; + gea::embedded::ui::StyleSheet::instance().startCssAnimations(static_cast(g_nowMs)); + gea::embedded::test::pumpFrame(g_nowMs); +} + +int onlyNodeWithClass(const char *className) +{ + const auto nodes = gea::embedded::test::nodesWithClass(className); + if (nodes.size() != 1) { + std::fprintf(stderr, "[%s] expected one .%s node, got %zu\n", kTest, className, nodes.size()); + return -1; + } + return nodes[0]; +} + +bool click(const char *text) +{ + if (!gea::embedded::test::clickFirstText(text, true)) { + std::fprintf(stderr, "[%s] no button labelled %s\n", kTest, text); + return false; + } + advance(16); + return true; +} + +bool hasAncestorWithClass(int node, const char *className) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + for (int id = node; id >= 0; id = tree.node(id).parent) { + if (tree.hasClass(id, className)) return true; + } + return false; +} + +// The visible .partClass nodes inside the bar whose root carries rootClass. +// nodesWithClass skips hidden nodes, so a display:none label is not counted. +std::vector partsOf(const char *rootClass, const char *partClass) +{ + std::vector out; + for (const int id : gea::embedded::test::nodesWithClass(partClass)) { + if (hasAncestorWithClass(id, rootClass)) out.push_back(id); + } + return out; +} + +int partOf(const char *rootClass, const char *partClass) +{ + const auto parts = partsOf(rootClass, partClass); + if (parts.size() != 1) { + std::fprintf(stderr, "[%s] expected one .%s in .%s, got %zu\n", kTest, partClass, rootClass, parts.size()); + return -1; + } + return parts[0]; +} + +// The bars are function components, so a re-render replaces their nodes: look +// them up again for every check instead of holding ids across clicks. +constexpr const char *kDemo = "demo-bar"; +int trackOf(const char *rootClass) { return partOf(rootClass, "progress-bar__track"); } +int fillOf(const char *rootClass) { return partOf(rootClass, "progress-bar__fill"); } + +// Fill width as a share of the track, in per-mille, so the check does not +// depend on the device pixel ratio or the demo's bar width. +int fillPermille(int track, int fill) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + if (track < 0 || fill < 0) return -1; + const int trackWidth = tree.node(track).layout.width; + if (trackWidth <= 0) return -1; + return (tree.node(fill).layout.width * 1000 + trackWidth / 2) / trackWidth; +} + +bool expectFill(const char *rootClass, int expectedPermille, const char *phase) +{ + const int track = trackOf(rootClass); + const int fill = fillOf(rootClass); + if (track < 0 || fill < 0) return false; + const int actual = fillPermille(track, fill); + if (actual < expectedPermille - 5 || actual > expectedPermille + 5) { + auto &tree = gea::embedded::ui::Tree::instance(); + std::fprintf(stderr, + "[%s] %s: fill %d/1000 of track, expected %d (fill=%d track=%d)\n", + kTest, + phase, + actual, + expectedPermille, + tree.node(fill).layout.width, + tree.node(track).layout.width); + return false; + } + return true; +} + +// The fill's authored width, in per-mille of the track, before layout rounds it +// to whole pixels and clamps a negative width to 0. A pixel check cannot tell +// 33.3% from 33% or -20% from 0%; this one can. +bool expectWidthPercent(const char *rootClass, int expectedPermille, const char *phase) +{ + const int fill = fillOf(rootClass); + if (fill < 0) return false; + const int actual = gea::embedded::ui::Tree::instance().node(fill).computedStyle().width_percent; + if (actual != expectedPermille) { + std::fprintf(stderr, "[%s] %s: fill width %d/1000, expected exactly %d\n", kTest, phase, actual, expectedPermille); + return false; + } + return true; +} + +// The fill sits at the start of its track whenever no sweep is running. +bool expectFillAtStart(const char *phase) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + const int track = trackOf(kDemo); + const int fill = fillOf(kDemo); + if (track < 0 || fill < 0) return false; + const int offset = tree.node(fill).layout.x - tree.node(track).layout.x; + if (offset != 0) { + std::fprintf(stderr, "[%s] %s: fill offset %d, expected 0\n", kTest, phase, offset); + return false; + } + return true; +} + +// An empty label is hidden and takes no space: the bar is as tall as its track. +bool expectNoLabel(const char *rootClass, const char *phase) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + const int root = onlyNodeWithClass(rootClass); + const int track = trackOf(rootClass); + if (root < 0 || track < 0) return false; + const std::size_t labels = partsOf(rootClass, "progress-bar__label").size(); + if (labels != 0 || tree.node(root).layout.height != tree.node(track).layout.height) { + std::fprintf(stderr, + "[%s] %s: expected no visible label, got %zu (bar %d tall, track %d)\n", + kTest, + phase, + labels, + tree.node(root).layout.height, + tree.node(track).layout.height); + return false; + } + return true; +} + +bool expectText(const char *needle, const char *phase) +{ + return gea::embedded::test::expectContains(gea::embedded::test::rootTextContent(), needle, phase, kTest); +} + +std::uint32_t rowChecksum(int y, int fromX, int toX) +{ + std::uint32_t sum = 0; + for (int x = fromX; x < toX; ++x) sum = sum * 31u + gea::embedded::test::displayPixelAt(x, y); + return sum; +} + +int trackMidY(int track) +{ + const auto &layout = gea::embedded::ui::Tree::instance().node(track).layout; + return layout.y + layout.height / 2; +} + +// A checksum of the painted pixels along the middle row of the demo track. It +// moves when the fill is painted somewhere else, whatever the animation drives. +std::uint32_t trackRowChecksum() +{ + const int track = trackOf(kDemo); + if (track < 0) return 0; + const auto &layout = gea::embedded::ui::Tree::instance().node(track).layout; + return rowChecksum(trackMidY(track), layout.x, layout.x + layout.width); +} + +// The same row left and right of the demo track. The fill must never paint +// there, or the track does not clip it. +std::uint32_t besideTrackChecksum() +{ + const int track = trackOf(kDemo); + if (track < 0) return 0; + const auto &layout = gea::embedded::ui::Tree::instance().node(track).layout; + const int y = trackMidY(track); + return rowChecksum(y, 0, layout.x) * 31u + rowChecksum(y, layout.x + layout.width, kViewportWidth); +} + +Pixel fillCenterPixel() +{ + const int fill = fillOf(kDemo); + if (fill < 0) return 0; + const auto &layout = gea::embedded::ui::Tree::instance().node(fill).layout; + return gea::embedded::test::displayPixelAt(layout.x + layout.width / 2, layout.y + layout.height / 2); +} + +int distinctChecksums(int frames, double stepMs) +{ + std::vector seen; + for (int i = 0; i < frames; ++i) { + advance(stepMs); + const std::uint32_t sum = trackRowChecksum(); + bool known = false; + for (const auto value : seen) known = known || value == sum; + if (!known) seen.push_back(sum); + } + return static_cast(seen.size()); +} + +// Steps through one sweep cycle and checks the fill starts left of the track, +// travels past 70% of its width, and is clipped to the track throughout. +bool expectSweep(double stepMs, int frames) +{ + auto &tree = gea::embedded::ui::Tree::instance(); + const std::uint32_t beside = besideTrackChecksum(); + int minOffset = 1 << 30; + int maxOffset = -(1 << 30); + std::vector seen; + for (int i = 0; i < frames; ++i) { + advance(stepMs); + const int track = trackOf(kDemo); + const int fill = fillOf(kDemo); + if (track < 0 || fill < 0) return false; + const int offset = tree.node(fill).layout.x - tree.node(track).layout.x; + if (offset < minOffset) minOffset = offset; + if (offset > maxOffset) maxOffset = offset; + if (besideTrackChecksum() != beside) { + std::fprintf(stderr, "[%s] busy: the fill painted outside the track at frame %d (offset %d)\n", kTest, i, offset); + return false; + } + const std::uint32_t sum = trackRowChecksum(); + bool known = false; + for (const auto value : seen) known = known || value == sum; + if (!known) seen.push_back(sum); + } + const int trackWidth = tree.node(trackOf(kDemo)).layout.width; + if (minOffset >= -trackWidth / 10 || maxOffset <= trackWidth * 7 / 10 || seen.size() < 3) { + std::fprintf(stderr, + "[%s] busy: sweep offsets %d..%d on a %d track, %zu distinct frames\n", + kTest, + minOffset, + maxOffset, + trackWidth, + seen.size()); + return false; + } + return true; +} +} // 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(); + advance(16); + + if (trackOf(kDemo) < 0 || fillOf(kDemo) < 0) { + dumpTree(kTest); + return 1; + } + const int wideTrack = tree.node(trackOf(kDemo)).layout.width; + bool ok = wideTrack > 0; + if (!ok) std::fprintf(stderr, "[%s] track has no width\n", kTest); + + // The fixed bars: no props draws empty with no label, min/max default to + // 0-100, the width keeps a tenth of a percent, a negative range works, and + // an inverted range or a NaN value draws empty. + ok = ok && expectFill("demo-case--defaults", 0, "no props"); + ok = ok && expectNoLabel("demo-case--defaults", "no props label"); + ok = ok && expectFill("demo-case--default-range", 250, "default 0-100 range"); + ok = ok && expectWidthPercent("demo-case--default-range", 250, "default 0-100 range width"); + ok = ok && expectFill("demo-case--fraction", 333, "fractional value"); + ok = ok && expectWidthPercent("demo-case--fraction", 333, "fractional value width"); + ok = ok && expectNoLabel("demo-case--fraction", "empty label"); + ok = ok && expectFill("demo-case--negative", 500, "negative range"); + ok = ok && expectWidthPercent("demo-case--inverted", 0, "inverted range"); + ok = ok && expectWidthPercent("demo-case--nan", 0, "NaN value"); + // A native build parses "NaN%" as 0, so the width alone cannot show that + // progressFraction catches NaN; the demo prints its result in the label. + ok = ok && expectText("NaN gives [0]", "NaN fraction"); + + // The app's id lands on the bar's root. + if (ok) { + const int root = onlyNodeWithClass(kDemo); + const int byId = gea::embedded::ui::Document::instance().getElementById("demo-bar").id(); + if (root < 0 || byId != root) { + std::fprintf(stderr, "[%s] id: #demo-bar is node %d, the .demo-bar root is %d\n", kTest, byId, root); + ok = false; + } + } + + ok = ok && expectFill(kDemo, 400, "initial 40/100"); + ok = ok && expectText("value 40 of 0-100", "initial label"); + const Pixel solidFill = fillCenterPixel(); + Pixel emptyTrack = 0; + if (ok) { + const auto &layout = tree.node(trackOf(kDemo)).layout; + emptyTrack = displayPixelAt(layout.x + layout.width - 4, trackMidY(trackOf(kDemo))); + if (solidFill == emptyTrack) { + std::fprintf(stderr, "[%s] initial: fill and track paint the same colour\n", kTest); + ok = false; + } + } + + ok = ok && click("Plus") && expectFill(kDemo, 650, "after Plus"); + ok = ok && click("Over") && expectFill(kDemo, 1000, "value above max"); + ok = ok && expectWidthPercent(kDemo, 1000, "value above max width"); + ok = ok && click("Under") && expectFill(kDemo, 0, "value below min"); + ok = ok && expectWidthPercent(kDemo, 0, "value below min width"); + + ok = ok && click("Reset") && click("Zero range") && expectFill(kDemo, 0, "zero range"); + ok = ok && expectText("value 40 of 50-50 (zero range)", "zero range label"); + + ok = ok && click("Reset") && click("Resize"); + if (ok) { + const int narrow = trackOf(kDemo); + if (narrow < 0 || tree.node(narrow).layout.width * 2 != wideTrack) { + std::fprintf(stderr, "[%s] resize: track %d, expected half of %d\n", kTest, narrow < 0 ? -1 : tree.node(narrow).layout.width, wideTrack); + ok = false; + } + } + ok = ok && expectFill(kDemo, 400, "after resize"); + + ok = ok && click("Reset") && click("Busy"); + ok = ok && expectText("Working", "busy label"); + if (ok && !tree.hasClass(fillOf(kDemo), "progress-bar__fill--indeterminate")) { + std::fprintf(stderr, "[%s] busy: fill lacks the indeterminate class\n", kTest); + ok = false; + } + ok = ok && expectFill(kDemo, 300, "busy segment"); + ok = ok && expectSweep(100, 12); + + ok = ok && click("Still"); + if (ok && (tree.hasClass(fillOf(kDemo), "progress-bar__fill--indeterminate") || !tree.hasClass(fillOf(kDemo), "progress-bar__fill--static"))) { + std::fprintf(stderr, "[%s] reduced motion: expected only the static class on the fill\n", kTest); + ok = false; + } + ok = ok && expectFill(kDemo, 1000, "reduced motion fill"); + ok = ok && expectFillAtStart("reduced motion offset"); + if (ok) { + advance(16); + const Pixel dimmed = fillCenterPixel(); + if (dimmed == solidFill || dimmed == emptyTrack) { + std::fprintf(stderr, "[%s] reduced motion: expected a dimmed fill, got %x (solid %x, track %x)\n", kTest, static_cast(dimmed), static_cast(solidFill), static_cast(emptyTrack)); + ok = false; + } + } + if (ok) { + const int distinct = distinctChecksums(12, 100); + if (distinct != 1) { + std::fprintf(stderr, "[%s] reduced motion: expected a still bar, %d distinct frames\n", kTest, distinct); + ok = false; + } + } + + ok = ok && click("Still") && click("Busy"); + if (ok && tree.hasClass(fillOf(kDemo), "progress-bar__fill--indeterminate")) { + std::fprintf(stderr, "[%s] idle: fill kept the indeterminate class\n", kTest); + ok = false; + } + ok = ok && expectFill(kDemo, 400, "back to determinate"); + ok = ok && expectFillAtStart("idle offset"); + if (ok) { + advance(16); + const int distinct = distinctChecksums(12, 100); + if (distinct != 1) { + std::fprintf(stderr, "[%s] idle: expected the sweep to stop, %d distinct frames\n", kTest, distinct); + ok = false; + } + } + + if (!ok) { + dumpTree(kTest); + return 1; + } + return 0; +} diff --git a/packages/elements/README.md b/packages/elements/README.md index 14ff8f1..9a9f486 100644 --- a/packages/elements/README.md +++ b/packages/elements/README.md @@ -11,6 +11,14 @@ npm install @geastack/elements an item template. - `CameraView`: a live camera preview bound to the host camera service. The `CameraController` types are re-exported from `@geastack/core`. +- `ProgressBar`: a determinate or indeterminate progress track built from plain + views, with a `value` clamped to `min`..`max` (0-100 by default), an optional + label and a `reducedMotion` switch that stills the indeterminate sweep. The + clamping is exported as `progressFraction(value, min, max)`. The sweep + distance is fixed in pixels when the animation starts, so a bar resized + while busy keeps the old distance until `indeterminate` is toggled. The bar + exposes no role or value to assistive technology and takes no keyboard + input, so put the value in `label` when the user needs to read it. ```tsx import { VirtualList } from '@geastack/elements' diff --git a/packages/elements/components/ProgressBar.css b/packages/elements/components/ProgressBar.css new file mode 100644 index 0000000..69cc43b --- /dev/null +++ b/packages/elements/components/ProgressBar.css @@ -0,0 +1,49 @@ +.progress-bar__track { + position: relative; + height: 8px; + overflow: hidden; + background-color: #e2ddd0; +} + +.progress-bar__fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + background-color: #171713; +} + +.progress-bar__fill--indeterminate { + animation: progress-bar-sweep 1200ms linear infinite; +} + +.progress-bar__fill--static { + opacity: 0.4; +} + +.progress-bar__label { + margin-top: 8px; + color: #171713; + font-size: 14px; +} + +@keyframes progress-bar-sweep { + from { + left: -30%; + } + to { + left: 100%; + } +} + +@media (monochrome) { + .progress-bar__track { + background-color: #d9d9d9; + } + .progress-bar__fill { + background-color: #000000; + } + .progress-bar__label { + color: #000000; + } +} diff --git a/packages/elements/components/ProgressBar.d.ts b/packages/elements/components/ProgressBar.d.ts new file mode 100644 index 0000000..e388077 --- /dev/null +++ b/packages/elements/components/ProgressBar.d.ts @@ -0,0 +1,35 @@ +import type { ClassValue } from '@geastack/core' + +export interface ProgressBarProps { + /** Current value, clamped to [min, max]. Defaults to 0. */ + value?: number + /** Defaults to 0. */ + min?: number + /** Defaults to 100. When max <= min the bar shows empty. */ + max?: number + /** + * Show an animated sweep instead of a value. The sweep distance is fixed in + * pixels when the animation starts, so a bar that changes width while busy + * keeps the old distance until `indeterminate` is toggled. + */ + indeterminate?: boolean + /** With `indeterminate`, show a still, dimmed full-width fill instead of the sweep. */ + reducedMotion?: boolean + /** Text shown under the track. Hidden when empty. */ + label?: string + id?: string + /** Applied to the root; size the bar here. Restyle parts with `.your-class .progress-bar__track`, `__fill`, `__label`. */ + class?: ClassValue +} + +/** The clamped position of `value` in [min, max], from 0 to 1. Returns 0 for an empty or inverted range and for NaN. */ +export declare function progressFraction(value: number, min: number, max: number): number + +/** + * A progress track composed from plain views. + * + * Accessibility: the bar exposes no role or value to assistive technology on + * any target, and it takes no keyboard input. Put the value in `label` when + * the user needs to read it. + */ +export default function ProgressBar(props: ProgressBarProps): any diff --git a/packages/elements/components/ProgressBar.tsx b/packages/elements/components/ProgressBar.tsx new file mode 100644 index 0000000..e78ab8e --- /dev/null +++ b/packages/elements/components/ProgressBar.tsx @@ -0,0 +1,72 @@ +import type { ClassValue } from '@geastack/core' +import './ProgressBar.css' + +// A horizontal progress track composed from plain views, so it renders the same +// on pixel targets, where the native tag is drawn as an empty box, +// and on desktop renderers. The app owns `value`; the bar only draws it. +// The caller's `class` lands on the root; the bar's own parts use fixed +// `progress-bar__*` classes so callers restyle them with descendant selectors +// instead of the component merging two ClassValues. There is no `style` +// prop: geatsc has no rule for forwarding a Style object through a function +// component, so size and place the bar through `class`. + +export interface ProgressBarProps { + value?: number + min?: number + max?: number + indeterminate?: boolean + reducedMotion?: boolean + label?: string + id?: string + class?: ClassValue +} + +// The negated comparisons also send NaN to 0, so a bad value never reaches the +// layout as a NaN width. +export function progressFraction(value: number, min: number, max: number): number { + const range = max - min + if (!(range > 0)) return 0 + const fraction = (value - min) / range + if (!(fraction > 0)) return 0 + if (fraction > 1) return 1 + return fraction +} + +function fillWidth(value: number, min: number, max: number, indeterminate: boolean, reducedMotion: boolean): `${number}%` { + if (indeterminate) return reducedMotion ? '100%' : '30%' + return `${Math.round(progressFraction(value, min, max) * 1000) / 10}%` +} + +export default function ProgressBar({ + value, + min, + max, + indeterminate, + reducedMotion, + label, + id, + class: cls +}: ProgressBarProps) { + const lo = min === undefined ? 0 : min + const hi = max === undefined ? 100 : max + const busy = indeterminate === true + const still = reducedMotion === true + const text = label === undefined ? '' : label + return ( +
+
+
+
+ 0 ? 'block' : 'none' }}> + {text} + +
+ ) +} diff --git a/packages/elements/components/index.d.ts b/packages/elements/components/index.d.ts index 91abb48..aa93705 100644 --- a/packages/elements/components/index.d.ts +++ b/packages/elements/components/index.d.ts @@ -3,3 +3,5 @@ export type { CameraViewProps } from './CameraView' export type { CameraFacing, CameraFlashMode, CameraDevice, CameraOpenOptions, CameraCaptureOptions, CameraPhoto, CameraController } from '@geastack/core' export { default as VirtualList } from './VirtualList' export type { VirtualListProps, VirtualListItemTemplate } from './VirtualList' +export { default as ProgressBar, progressFraction } from './ProgressBar' +export type { ProgressBarProps } from './ProgressBar' diff --git a/packages/elements/components/index.ts b/packages/elements/components/index.ts index 992a63c..d028f0d 100644 --- a/packages/elements/components/index.ts +++ b/packages/elements/components/index.ts @@ -1,2 +1,4 @@ export { CameraView } from './CameraView' export { default as VirtualList } from './VirtualList' +export { default as ProgressBar, progressFraction } from './ProgressBar' +export type { ProgressBarProps } from './ProgressBar' diff --git a/packages/elements/package.json b/packages/elements/package.json index a28afec..6b8af1e 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -26,6 +26,10 @@ "types": "./components/CameraView.d.ts", "default": "./components/CameraView.tsx" }, + "./components/ProgressBar": { + "types": "./components/ProgressBar.d.ts", + "default": "./components/ProgressBar.tsx" + }, "./components/VirtualList": { "types": "./components/VirtualList.d.ts", "default": "./components/VirtualList.tsx" @@ -39,7 +43,9 @@ "publishConfig": { "access": "public" }, - "sideEffects": false, + "sideEffects": [ + "*.css" + ], "dependencies": { "@geastack/core": "^0.1.40" }