From 8e9a44d744e8c95fd9873f8edd3d4c4aa9ae0a28 Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 17:42:49 -0400 Subject: [PATCH 01/19] feat(rockbox): add iPod classic PocketJS host --- hosts/iphone2g/pocket_runtime.c | 33 ++- hosts/iphone2g/pocket_runtime.h | 2 + hosts/rockbox/README.md | 53 +++++ hosts/rockbox/compat.c | 29 +++ hosts/rockbox/compat.h | 111 +++++++++ hosts/rockbox/demo.pocket.json | 24 ++ hosts/rockbox/demo/main.tsx | 46 ++++ hosts/rockbox/framebuffer.c | 14 ++ hosts/rockbox/framebuffer.h | 9 + hosts/rockbox/input.c | 30 +++ hosts/rockbox/input.h | 25 ++ hosts/rockbox/main.c | 125 ++++++++++ hosts/rockbox/pocketjs.make | 27 +++ hosts/rockbox/qjs_config.h | 10 + hosts/rockbox/qjs_cutils.c | 2 + hosts/rockbox/qjs_dtoa.c | 2 + hosts/rockbox/qjs_libregexp.c | 2 + hosts/rockbox/qjs_libunicode.c | 2 + hosts/rockbox/qjs_quickjs.c | 2 + hosts/rockbox/runtime_port.c | 6 + hosts/rockbox/sys/time.h | 13 ++ .../rockbox/targets/armv5te-rockbox-eabi.json | 23 ++ hosts/rockbox/tests/platform_test.c | 47 ++++ package.json | 1 + tests/rockbox-profile.test.ts | 32 +++ tools/rockbox-profile.ts | 46 ++++ tools/rockbox.ts | 217 ++++++++++++++++++ tools/rockbox/quickjs.patch | 33 +++ 28 files changed, 960 insertions(+), 6 deletions(-) create mode 100644 hosts/rockbox/README.md create mode 100644 hosts/rockbox/compat.c create mode 100644 hosts/rockbox/compat.h create mode 100644 hosts/rockbox/demo.pocket.json create mode 100644 hosts/rockbox/demo/main.tsx create mode 100644 hosts/rockbox/framebuffer.c create mode 100644 hosts/rockbox/framebuffer.h create mode 100644 hosts/rockbox/input.c create mode 100644 hosts/rockbox/input.h create mode 100644 hosts/rockbox/main.c create mode 100644 hosts/rockbox/pocketjs.make create mode 100644 hosts/rockbox/qjs_config.h create mode 100644 hosts/rockbox/qjs_cutils.c create mode 100644 hosts/rockbox/qjs_dtoa.c create mode 100644 hosts/rockbox/qjs_libregexp.c create mode 100644 hosts/rockbox/qjs_libunicode.c create mode 100644 hosts/rockbox/qjs_quickjs.c create mode 100644 hosts/rockbox/runtime_port.c create mode 100644 hosts/rockbox/sys/time.h create mode 100644 hosts/rockbox/targets/armv5te-rockbox-eabi.json create mode 100644 hosts/rockbox/tests/platform_test.c create mode 100644 tests/rockbox-profile.test.ts create mode 100644 tools/rockbox-profile.ts create mode 100644 tools/rockbox.ts create mode 100644 tools/rockbox/quickjs.patch diff --git a/hosts/iphone2g/pocket_runtime.c b/hosts/iphone2g/pocket_runtime.c index 44990522f..4377c3129 100644 --- a/hosts/iphone2g/pocket_runtime.c +++ b/hosts/iphone2g/pocket_runtime.c @@ -27,6 +27,10 @@ extern void pocket_host_boot_stage(int stage); #define REPORT_BOOT_STAGE(stage) ((void)(stage)) #endif +#ifndef POCKET_RUNTIME_JS_STACK_SIZE +#define POCKET_RUNTIME_JS_STACK_SIZE (256 * 1024) +#endif + typedef enum { HostCreateNode, HostDestroyNode, @@ -561,7 +565,7 @@ int pocket_runtime_boot( return 0; } REPORT_BOOT_STAGE(4); - JS_SetMaxStackSize(runtime, 256 * 1024); + JS_SetMaxStackSize(runtime, POCKET_RUNTIME_JS_STACK_SIZE); context = JS_NewContext(runtime); if (context == 0) { set_error("QuickJS context allocation failed"); @@ -632,6 +636,7 @@ int pocket_runtime_boot( /* One guest turn (frame call + job drain), then `tick_count` core ticks. */ static int run_frame( uint32_t buttons, + uint32_t analog, const PocketRuntimeContact *contacts, unsigned int contact_count, unsigned int tick_count @@ -680,7 +685,7 @@ static int run_frame( } JSValue arguments[4] = { JS_NewUint32(context, buttons), - JS_NewInt32(context, POCKET_ANALOG_CENTER), + JS_NewUint32(context, analog), touch_array, hit_array, }; @@ -728,12 +733,22 @@ int pocket_runtime_tick(const PocketRuntimeInput *input) { input->touch_y, input->touch_hit ); - return run_frame(input->buttons, &contact, count, 1); + return run_frame(input->buttons, POCKET_ANALOG_CENTER, &contact, count, 1); +} + +int pocket_runtime_tick_analog(uint32_t buttons, uint32_t analog) { + return run_frame(buttons, analog, 0, 0, 1); } int pocket_runtime_tick_contacts(const PocketRuntimeContactsInput *input) { if (input == 0) return 0; - return run_frame(input->buttons, input->contacts, input->contact_count, 1); + return run_frame( + input->buttons, + POCKET_ANALOG_CENTER, + input->contacts, + input->contact_count, + 1 + ); } int pocket_runtime_frame_contacts( @@ -741,7 +756,13 @@ int pocket_runtime_frame_contacts( unsigned int tick_count ) { if (input == 0) return 0; - return run_frame(input->buttons, input->contacts, input->contact_count, tick_count); + return run_frame( + input->buttons, + POCKET_ANALOG_CENTER, + input->contacts, + input->contact_count, + tick_count + ); } int pocket_runtime_frame_ticks( @@ -753,7 +774,7 @@ int pocket_runtime_frame_ticks( ) { PocketRuntimeContact contact; unsigned int count = single_contact(&contact, touch_down, touch_x, touch_y, touch_hit); - return run_frame(0, &contact, count, tick_count); + return run_frame(0, POCKET_ANALOG_CENTER, &contact, count, tick_count); } int pocket_runtime_frame(int touch_down, int touch_x, int touch_y, int touch_hit) { diff --git a/hosts/iphone2g/pocket_runtime.h b/hosts/iphone2g/pocket_runtime.h index ca6bad89a..39ed09b77 100644 --- a/hosts/iphone2g/pocket_runtime.h +++ b/hosts/iphone2g/pocket_runtime.h @@ -28,6 +28,8 @@ typedef struct { int touch_hit; } PocketRuntimeInput; int pocket_runtime_tick(const PocketRuntimeInput *input); +/* Button + analog-only entry for hosts with a wheel/stick and no touch. */ +int pocket_runtime_tick_analog(uint32_t buttons, uint32_t analog); /* * Multi-contact frame entry. `id` is the host's contact slot (0-255, stable diff --git a/hosts/rockbox/README.md b/hosts/rockbox/README.md new file mode 100644 index 000000000..555f727dc --- /dev/null +++ b/hosts/rockbox/README.md @@ -0,0 +1,53 @@ +# PocketJS for Rockbox iPod classic + +This host embeds a PocketJS application in a Rockbox plugin for the 6th/7th +generation iPod classic (`ipod6g`). It targets the native 320x240 RGB565 LCD +and ARM926EJ-S/ARMv5TE CPU. The current profile is development-only and uses +the software rasterizer, QuickJS, and Rockbox's remaining plugin buffer as a +TLSF heap. + +## Controls + +| iPod control | PocketJS input | +| --- | --- | +| Select | Circle / confirm | +| Menu | Triangle / back | +| Left, Right | Left, Right | +| Play/Pause | Start | +| Wheel clockwise, counter-clockwise | Down, Up | +| Hold Menu | Exit plugin | + +## Build + +Rockbox recommends building its ARM cross compiler with `tools/rockboxdev.sh`. +For an existing Rockbox source checkout and toolchain: + +```sh +bun install --frozen-lockfile +bun rockbox bootstrap +ROCKBOX_SOURCE=/path/to/rockbox bun rockbox test +ROCKBOX_SOURCE=/path/to/rockbox bun rockbox build +``` + +The hardware artifact is written to: + +```text +dist/rockbox/pocketjs-ipod6g.rock +``` + +To package a different app, pass its manifest: + +```sh +ROCKBOX_SOURCE=/path/to/rockbox \ + bun rockbox build --manifest=/path/to/app.pocket.json +``` + +Copy the resulting file to `.rockbox/rocks/apps/pocketjs.rock` on the mounted +iPod, eject it cleanly, then launch it from **Plugins > Applications**. + +## Current boundary + +This first host exposes baked text, software rendering, and click-wheel/button +input. Audio, networking, filesystem APIs, and arbitrary logical viewport +scaling are not advertised by the target profile. A successful cross-build or +USB copy does not replace an on-device launch/input test. diff --git a/hosts/rockbox/compat.c b/hosts/rockbox/compat.c new file mode 100644 index 000000000..36ec4dbe0 --- /dev/null +++ b/hosts/rockbox/compat.c @@ -0,0 +1,29 @@ +#include "compat.h" + +#include + +static int pocket_errno; + +int *__errno(void) { return &pocket_errno; } + +int gettimeofday(struct timeval *tv, void *timezone) { + long ticks; + (void)timezone; + if (tv == 0) return -1; + ticks = *rb->current_tick; + tv->tv_sec = ticks / HZ; + tv->tv_usec = (ticks % HZ) * (1000000L / HZ); + return 0; +} + +void abort(void) { + rb->splash(HZ * 3, "PocketJS: native abort"); + exit(PLUGIN_ERROR); + while (true) { } +} + +/* Rust is built with panic=abort. These personality symbols are still named + by ARM exception metadata but must never begin a real unwind in Rockbox. */ +void __aeabi_unwind_cpp_pr0(void) { abort(); } +void __aeabi_unwind_cpp_pr1(void) { abort(); } +void __aeabi_unwind_cpp_pr2(void) { abort(); } diff --git a/hosts/rockbox/compat.h b/hosts/rockbox/compat.h new file mode 100644 index 000000000..005337739 --- /dev/null +++ b/hosts/rockbox/compat.h @@ -0,0 +1,111 @@ +#ifndef POCKETJS_ROCKBOX_COMPAT_H +#define POCKETJS_ROCKBOX_COMPAT_H + +#include "plugin.h" +#include + +/* Rockbox's container_of type-check rejects QuickJS flexible-array members. */ +#undef container_of +#define container_of(ptr, type, member) \ + ((type *)((char *)(ptr) - offsetof(type, member))) + +/* QuickJS and the no_std Rust core share Rockbox's remaining plugin buffer. */ +#define malloc tlsf_malloc +#define calloc tlsf_calloc +#define realloc tlsf_realloc +#define free tlsf_free + +#define memcpy rb->memcpy +#define memmove rb->memmove +#define memset rb->memset +#define memcmp rb->memcmp +#define memchr rb->memchr +#define strlen rb->strlen +#define strcmp rb->strcmp +#define strncmp rb->strncmp +#define strcpy rb->strcpy +#define strncpy rb->strncpy +#define strchr rb->strchr +#define strrchr rb->strrchr +#define strstr rb->strstr +#define strtol rb->strtol +#define strtoul rb->strtoul +#define qsort rb->qsort +#define snprintf rb->snprintf +#define vsnprintf rb->vsnprintf + +/* Rockbox deliberately ships a tiny math.h. The hardware build links + newlib's soft-float libm, so expose the C99 declarations QuickJS needs. */ +double acos(double); +double acosh(double); +double asin(double); +double asinh(double); +double atan(double); +double atan2(double, double); +double atanh(double); +double cbrt(double); +double ceil(double); +double cos(double); +double cosh(double); +double exp(double); +double expm1(double); +double fabs(double); +double floor(double); +double fmax(double, double); +double fmin(double, double); +double fmod(double, double); +double hypot(double, double); +double log(double); +double log1p(double); +double log2(double); +double log10(double); +double pow(double, double); +double round(double); +long int lrint(double); +double sin(double); +double sinh(double); +double sqrt(double); +double tan(double); +double tanh(double); +double trunc(double); + +#ifndef isnan +#define isnan(value) __builtin_isnan(value) +#endif +#ifndef isfinite +#define isfinite(value) __builtin_isfinite(value) +#endif +#ifndef isinf +#define isinf(value) __builtin_isinf(value) +#endif +#ifndef signbit +#define signbit(value) __builtin_signbit(value) +#endif +#ifndef NAN +#define NAN __builtin_nanf("") +#endif +#ifndef INFINITY +#define INFINITY __builtin_inf() +#endif + +void abort(void) __attribute__((noreturn)); +void exit(int status) __attribute__((noreturn)); + +/* Hardware Rockbox deliberately omits stdio streams. QuickJS only uses these + in its optional diagnostics, which stay silent in this plugin. */ +#ifndef SIMULATOR +typedef void FILE; +#define stdout ((FILE *)0) +#define stderr ((FILE *)0) +#define putchar(value) (value) +#define fputc(value, stream) ((void)(stream), (value)) +#define fwrite(ptr, size, count, stream) \ + ((void)(ptr), (void)(size), (void)(stream), (count)) +#endif + +/* Diagnostic-only QuickJS printers are retained but silent on-device. */ +#define printf(...) ((void)0) +#define fprintf(stream, ...) ((void)(stream), 0) +#define puts(value) ((void)(value), 0) + +#endif diff --git a/hosts/rockbox/demo.pocket.json b/hosts/rockbox/demo.pocket.json new file mode 100644 index 000000000..a5af8ccc2 --- /dev/null +++ b/hosts/rockbox/demo.pocket.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.rockbox-ipod-classic", + "name": "pocketjs-rockbox-ipod-classic", + "title": "PocketJS — iPod classic", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": ["input.buttons", "text.glyphs.baked"] + } + }, + "app": { + "entry": "hosts/rockbox/demo/main.tsx", + "output": "rockbox-ipod-classic-demo", + "framework": "solid", + "viewport": { + "fixed": { + "logical": [320, 240], + "presentation": "native" + } + } + } +} diff --git a/hosts/rockbox/demo/main.tsx b/hosts/rockbox/demo/main.tsx new file mode 100644 index 000000000..dd5450e12 --- /dev/null +++ b/hosts/rockbox/demo/main.tsx @@ -0,0 +1,46 @@ +import { createSignal } from "solid-js"; +import { mount } from "@pocketjs/framework"; +import { Text, View } from "@pocketjs/framework/components"; +import { BTN } from "@pocketjs/framework/input"; +import { onButtonPress } from "@pocketjs/framework/lifecycle"; + +function Demo() { + const [selection, setSelection] = createSignal(0); + const [lastInput, setLastInput] = createSignal("READY"); + const items = ["PocketJS", "Rockbox", "iPod classic"] as const; + + onButtonPress(BTN.UP, () => { + setSelection((value) => (value + items.length - 1) % items.length); + setLastInput("WHEEL BACK"); + }); + onButtonPress(BTN.DOWN, () => { + setSelection((value) => (value + 1) % items.length); + setLastInput("WHEEL FORWARD"); + }); + onButtonPress(BTN.LEFT, () => setLastInput("LEFT")); + onButtonPress(BTN.RIGHT, () => setLastInput("RIGHT")); + onButtonPress(BTN.CIRCLE, () => setLastInput("SELECT")); + onButtonPress(BTN.TRIANGLE, () => setLastInput("MENU")); + onButtonPress(BTN.START, () => setLastInput("PLAY/PAUSE")); + + return ( + + PocketJS on Rockbox + iPod classic 6G / 7G + + {items.map((item, index) => ( + + {item} + + ))} + + LAST INPUT + {lastInput()} + Hold MENU to exit + + ); +} + +mount(() => ); diff --git a/hosts/rockbox/framebuffer.c b/hosts/rockbox/framebuffer.c new file mode 100644 index 000000000..211afedb0 --- /dev/null +++ b/hosts/rockbox/framebuffer.c @@ -0,0 +1,14 @@ +#include "framebuffer.h" + +void rockbox_bgra_to_rgb565(uint16_t *out, const uint8_t *bgra, size_t pixels) { + size_t index; + if (out == 0 || bgra == 0) return; + for (index = 0; index < pixels; ++index) { + const uint8_t blue = bgra[index * 4u]; + const uint8_t green = bgra[index * 4u + 1u]; + const uint8_t red = bgra[index * 4u + 2u]; + out[index] = (uint16_t)(((uint16_t)(red & 0xf8u) << 8u) | + ((uint16_t)(green & 0xfcu) << 3u) | + ((uint16_t)blue >> 3u)); + } +} diff --git a/hosts/rockbox/framebuffer.h b/hosts/rockbox/framebuffer.h new file mode 100644 index 000000000..d34351e57 --- /dev/null +++ b/hosts/rockbox/framebuffer.h @@ -0,0 +1,9 @@ +#ifndef POCKETJS_ROCKBOX_FRAMEBUFFER_H +#define POCKETJS_ROCKBOX_FRAMEBUFFER_H + +#include +#include + +void rockbox_bgra_to_rgb565(uint16_t *out, const uint8_t *bgra, size_t pixels); + +#endif diff --git a/hosts/rockbox/input.c b/hosts/rockbox/input.c new file mode 100644 index 000000000..4a790de43 --- /dev/null +++ b/hosts/rockbox/input.c @@ -0,0 +1,30 @@ +#include "input.h" + +#include "pocket_spec.h" + +uint32_t rockbox_input_buttons( + int held, + int event, + const RockboxInputCodes *codes +) { + uint32_t buttons = 0; + int physical; + if (codes == 0) return 0; + + physical = held | event; + if ((physical & codes->select) != 0) buttons |= POCKET_BTN_CIRCLE; + if ((physical & codes->menu) != 0) buttons |= POCKET_BTN_TRIANGLE; + if ((physical & codes->left) != 0) buttons |= POCKET_BTN_LEFT; + if ((physical & codes->right) != 0) buttons |= POCKET_BTN_RIGHT; + if ((physical & codes->play) != 0) buttons |= POCKET_BTN_START; + + /* Wheel motion is queued as an event, not reported by button_status(). */ + if ((event & codes->scroll_forward) != 0) buttons |= POCKET_BTN_DOWN; + if ((event & codes->scroll_back) != 0) buttons |= POCKET_BTN_UP; + return buttons; +} + +bool rockbox_input_exit_requested(int event, const RockboxInputCodes *codes) { + if (codes == 0) return false; + return (event & codes->menu) != 0 && (event & codes->repeat) != 0; +} diff --git a/hosts/rockbox/input.h b/hosts/rockbox/input.h new file mode 100644 index 000000000..65c38f371 --- /dev/null +++ b/hosts/rockbox/input.h @@ -0,0 +1,25 @@ +#ifndef POCKETJS_ROCKBOX_INPUT_H +#define POCKETJS_ROCKBOX_INPUT_H + +#include +#include + +typedef struct { + int select; + int menu; + int left; + int right; + int play; + int scroll_forward; + int scroll_back; + int repeat; +} RockboxInputCodes; + +uint32_t rockbox_input_buttons( + int held, + int event, + const RockboxInputCodes *codes +); +bool rockbox_input_exit_requested(int event, const RockboxInputCodes *codes); + +#endif diff --git a/hosts/rockbox/main.c b/hosts/rockbox/main.c new file mode 100644 index 000000000..130ce5b4a --- /dev/null +++ b/hosts/rockbox/main.c @@ -0,0 +1,125 @@ +#include "plugin.h" +#include + +#include "framebuffer.h" +#include "input.h" +#include "pocket_runtime.h" +#include "pocket_spec.h" + +#if CONFIG_KEYPAD != IPOD_4G_PAD +#error "PocketJS Rockbox host currently supports iPod classic click-wheel targets only" +#endif +#if LCD_WIDTH != 320 || LCD_HEIGHT != 240 || LCD_DEPTH != 16 +#error "PocketJS Rockbox host requires the iPod classic 320x240 RGB565 display" +#endif + +extern const unsigned char pocket_app_js[]; +extern const unsigned int pocket_app_js_len; +extern const unsigned char pocket_app_pak[]; +extern const unsigned int pocket_app_pak_len; + +static fb_data display[LCD_WIDTH * LCD_HEIGHT] MEM_ALIGN_ATTR; + +static const RockboxInputCodes input_codes = { + .select = BUTTON_SELECT, + .menu = BUTTON_MENU, + .left = BUTTON_LEFT, + .right = BUTTON_RIGHT, + .play = BUTTON_PLAY, + .scroll_forward = BUTTON_SCROLL_FWD, + .scroll_back = BUTTON_SCROLL_BACK, + .repeat = BUTTON_REPEAT, +}; + +void *pocket_host_alloc(size_t size) { return tlsf_malloc(size); } +void *pocket_host_realloc(void *pointer, size_t size) { + return tlsf_realloc(pointer, size); +} +void pocket_host_free(void *pointer) { tlsf_free(pointer); } + +static enum plugin_status show_runtime_error(void) { + const char *message = pocket_runtime_error(); + rb->splashf(HZ * 4, "PocketJS: %s", message && *message ? message : "runtime error"); + return PLUGIN_ERROR; +} + +enum plugin_status plugin_start(const void *parameter) { + enum plugin_status status = PLUGIN_OK; + size_t heap_size = 0; + void *heap; + int pending_event = BUTTON_NONE; + int cadence = 0; + bool runtime_ready = false; + + (void)parameter; + heap = rb->plugin_get_buffer(&heap_size); + if (heap == 0 || heap_size < 256u * 1024u || + init_memory_pool(heap_size, heap) == (size_t)-1) { + rb->splash(HZ * 3, "PocketJS: not enough plugin memory"); + return PLUGIN_ERROR; + } + +#ifdef HAVE_ADJUSTABLE_CPU_FREQ + rb->cpu_boost(true); +#endif + rb->backlight_on(); + + if (!pocket_runtime_boot( + pocket_app_js, + pocket_app_js_len, + pocket_app_pak, + pocket_app_pak_len, + LCD_WIDTH, + LCD_HEIGHT + )) { + status = show_runtime_error(); + goto cleanup; + } + runtime_ready = true; + + while (true) { + const long event = rb->button_get_w_tmo(1); + uint32_t buttons; + const uint8_t *pixels; + + if (event != BUTTON_NONE) { + if (rockbox_input_exit_requested((int)event, &input_codes)) break; + if (rb->default_event_handler(event) == SYS_USB_CONNECTED) { + status = PLUGIN_USB_CONNECTED; + break; + } + pending_event |= (int)event; + } + + /* Rockbox's native tick is normally 100 Hz; retain exactly 60 guest + turns per second without relying on fractional sleep durations. */ + cadence += 60; + if (cadence < HZ) continue; + cadence -= HZ; + + buttons = rockbox_input_buttons(rb->button_status(), pending_event, &input_codes); + pending_event = BUTTON_NONE; + if (!pocket_runtime_tick_analog(buttons, POCKET_ANALOG_CENTER)) { + status = show_runtime_error(); + break; + } + + pixels = pocket_runtime_render(); + if (pixels == 0 || pocket_runtime_width() != LCD_WIDTH || + pocket_runtime_height() != LCD_HEIGHT) { + rb->splash(HZ * 3, "PocketJS: invalid framebuffer"); + status = PLUGIN_ERROR; + break; + } + rockbox_bgra_to_rgb565((uint16_t *)display, pixels, LCD_WIDTH * LCD_HEIGHT); + rb->lcd_bitmap(display, 0, 0, LCD_WIDTH, LCD_HEIGHT); + rb->lcd_update(); + } + +cleanup: + if (runtime_ready) pocket_runtime_shutdown(); +#ifdef HAVE_ADJUSTABLE_CPU_FREQ + rb->cpu_boost(false); +#endif + return status; +} diff --git a/hosts/rockbox/pocketjs.make b/hosts/rockbox/pocketjs.make new file mode 100644 index 000000000..a113146e0 --- /dev/null +++ b/hosts/rockbox/pocketjs.make @@ -0,0 +1,27 @@ +POCKETJS_SRCDIR := $(APPSDIR)/plugins/pocketjs +POCKETJS_BUILDDIR := $(BUILDDIR)/apps/plugins/pocketjs + +POCKETJS_SRC := \ + $(POCKETJS_SRCDIR)/main.c \ + $(POCKETJS_SRCDIR)/input.c \ + $(POCKETJS_SRCDIR)/framebuffer.c \ + $(POCKETJS_SRCDIR)/compat.c \ + $(POCKETJS_SRCDIR)/runtime_port.c \ + $(POCKETJS_SRCDIR)/app_data.c \ + $(POCKETJS_SRCDIR)/qjs_quickjs.c \ + $(POCKETJS_SRCDIR)/qjs_cutils.c \ + $(POCKETJS_SRCDIR)/qjs_libregexp.c \ + $(POCKETJS_SRCDIR)/qjs_libunicode.c \ + $(POCKETJS_SRCDIR)/qjs_dtoa.c +POCKETJS_OBJ := $(call c2obj,$(POCKETJS_SRC)) +POCKETJS_CORE := $(POCKETJS_SRCDIR)/libpocketjs_rockbox_core.a + +OTHER_SRC += $(POCKETJS_SRC) +ROCKS += $(POCKETJS_BUILDDIR)/pocketjs.rock + +$(POCKETJS_OBJ): $(BUILDDIR)/sysfont.h +$(POCKETJS_BUILDDIR)/pocketjs.rock: $(POCKETJS_OBJ) $(POCKETJS_CORE) $(TLSFLIB) +$(POCKETJS_BUILDDIR)/pocketjs.rock: PLUGINFLAGS += \ + -I$(POCKETJS_SRCDIR) \ + -Wno-sign-compare -Wno-unused-parameter +$(POCKETJS_BUILDDIR)/pocketjs.rock: PLUGINLDFLAGS += -lm diff --git a/hosts/rockbox/qjs_config.h b/hosts/rockbox/qjs_config.h new file mode 100644 index 000000000..58fed2a6a --- /dev/null +++ b/hosts/rockbox/qjs_config.h @@ -0,0 +1,10 @@ +#ifndef POCKETJS_ROCKBOX_QJS_CONFIG_H +#define POCKETJS_ROCKBOX_QJS_CONFIG_H + +#define POCKETJS_NO_MALLOC_USABLE_SIZE 1 +#define POCKETJS_NO_ATOMICS 1 +#define POCKETJS_FIXED_TIMEZONE 1 +#define CONFIG_VERSION "pocket-rockbox-ipod-classic" +#include "compat.h" + +#endif diff --git a/hosts/rockbox/qjs_cutils.c b/hosts/rockbox/qjs_cutils.c new file mode 100644 index 000000000..73ccf6e54 --- /dev/null +++ b/hosts/rockbox/qjs_cutils.c @@ -0,0 +1,2 @@ +#include "qjs_config.h" +#include "cutils.c" diff --git a/hosts/rockbox/qjs_dtoa.c b/hosts/rockbox/qjs_dtoa.c new file mode 100644 index 000000000..b245a447d --- /dev/null +++ b/hosts/rockbox/qjs_dtoa.c @@ -0,0 +1,2 @@ +#include "qjs_config.h" +#include "dtoa.c" diff --git a/hosts/rockbox/qjs_libregexp.c b/hosts/rockbox/qjs_libregexp.c new file mode 100644 index 000000000..30dd18bc0 --- /dev/null +++ b/hosts/rockbox/qjs_libregexp.c @@ -0,0 +1,2 @@ +#include "qjs_config.h" +#include "libregexp.c" diff --git a/hosts/rockbox/qjs_libunicode.c b/hosts/rockbox/qjs_libunicode.c new file mode 100644 index 000000000..a20bb292c --- /dev/null +++ b/hosts/rockbox/qjs_libunicode.c @@ -0,0 +1,2 @@ +#include "qjs_config.h" +#include "libunicode.c" diff --git a/hosts/rockbox/qjs_quickjs.c b/hosts/rockbox/qjs_quickjs.c new file mode 100644 index 000000000..c3565ee45 --- /dev/null +++ b/hosts/rockbox/qjs_quickjs.c @@ -0,0 +1,2 @@ +#include "qjs_config.h" +#include "quickjs.c" diff --git a/hosts/rockbox/runtime_port.c b/hosts/rockbox/runtime_port.c new file mode 100644 index 000000000..677096a2b --- /dev/null +++ b/hosts/rockbox/runtime_port.c @@ -0,0 +1,6 @@ +#define POCKETJS_TARGET_ID "rockbox-ipod-classic-dev" +#define POCKETJS_HOST_ABI 9 +#define POCKET_RASTER_DENSITY 1 +#define POCKET_RUNTIME_JS_STACK_SIZE 65536 +#include "compat.h" +#include "pocket_runtime.c" diff --git a/hosts/rockbox/sys/time.h b/hosts/rockbox/sys/time.h new file mode 100644 index 000000000..e06399729 --- /dev/null +++ b/hosts/rockbox/sys/time.h @@ -0,0 +1,13 @@ +#ifndef POCKETJS_ROCKBOX_SYS_TIME_H +#define POCKETJS_ROCKBOX_SYS_TIME_H + +#include + +struct timeval { + time_t tv_sec; + long tv_usec; +}; + +int gettimeofday(struct timeval *tv, void *timezone); + +#endif diff --git a/hosts/rockbox/targets/armv5te-rockbox-eabi.json b/hosts/rockbox/targets/armv5te-rockbox-eabi.json new file mode 100644 index 000000000..b328faabe --- /dev/null +++ b/hosts/rockbox/targets/armv5te-rockbox-eabi.json @@ -0,0 +1,23 @@ +{ + "abi": "eabi", + "arch": "arm", + "c-enum-min-bits": 8, + "crt-objects-fallback": "false", + "cpu": "arm926ej-s", + "data-layout": "e-m:e-p:32:32-Fi8-i64:64-v128:64:128-a:0:32-n32-S64", + "emit-debug-gdb-scripts": false, + "features": "+v5te,+soft-float,+strict-align", + "frame-pointer": "always", + "has-thumb-interworking": true, + "linker": "rust-lld", + "linker-flavor": "gnu-lld", + "llvm-floatabi": "soft", + "llvm-target": "armv5te-none-eabi", + "max-atomic-width": 32, + "os": "none", + "panic-strategy": "abort", + "relocation-model": "static", + "target-endian": "little", + "target-pointer-width": 32, + "vendor": "unknown" +} diff --git a/hosts/rockbox/tests/platform_test.c b/hosts/rockbox/tests/platform_test.c new file mode 100644 index 000000000..0e60b67d0 --- /dev/null +++ b/hosts/rockbox/tests/platform_test.c @@ -0,0 +1,47 @@ +#include +#include + +#include "../framebuffer.h" +#include "../input.h" +#include "../../iphone2g/pocket_spec.h" + +static void framebuffer_test(void) { + const uint8_t bgra[] = { + 0x00, 0x00, 0xff, 0xff, + 0x00, 0xff, 0x00, 0xff, + 0xff, 0x00, 0x00, 0xff, + 0xff, 0xff, 0xff, 0xff, + }; + uint16_t rgb565[4] = {0}; + rockbox_bgra_to_rgb565(rgb565, bgra, 4); + assert(rgb565[0] == 0xf800u); + assert(rgb565[1] == 0x07e0u); + assert(rgb565[2] == 0x001fu); + assert(rgb565[3] == 0xffffu); +} + +static void input_test(void) { + const RockboxInputCodes codes = { + .select = 1 << 0, + .menu = 1 << 1, + .left = 1 << 2, + .right = 1 << 3, + .scroll_forward = 1 << 4, + .scroll_back = 1 << 5, + .play = 1 << 6, + .repeat = 1 << 7, + }; + assert(rockbox_input_buttons(codes.select, 0, &codes) == POCKET_BTN_CIRCLE); + assert(rockbox_input_buttons(0, codes.scroll_forward, &codes) == POCKET_BTN_DOWN); + assert(rockbox_input_buttons(0, codes.scroll_back, &codes) == POCKET_BTN_UP); + assert(rockbox_input_buttons(codes.left | codes.play, 0, &codes) == + (POCKET_BTN_LEFT | POCKET_BTN_START)); + assert(rockbox_input_exit_requested(codes.menu | codes.repeat, &codes)); + assert(!rockbox_input_exit_requested(codes.menu, &codes)); +} + +int main(void) { + framebuffer_test(); + input_test(); + return 0; +} diff --git a/package.json b/package.json index b95cef37e..0fbad207c 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ }, "description": "A portable application runtime that turns modern component code into native pixels across radically different hardware: Solid, Vue Vapor and Octane over a native Rust core, with build-time Tailwind styling and 60 FPS animation under an 8 MB memory budget.", "scripts": { + "rockbox": "bun tools/rockbox.ts", "bootstrap": "bun tools/bootstrap.ts", "pocket": "bun tools/pocket.ts", "build": "bun tools/build.ts", diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts new file mode 100644 index 000000000..66fab7aba --- /dev/null +++ b/tests/rockbox-profile.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + ROCKBOX_IPOD_CLASSIC_HOST_ABI, + ROCKBOX_IPOD_CLASSIC_TARGET_ID, + resolveRockboxBuildPlan, +} from "../tools/rockbox-profile.ts"; + +const root = join(import.meta.dir, ".."); +const manifest = JSON.parse( + readFileSync(join(root, "hosts/rockbox/demo.pocket.json"), "utf8"), +); + +describe("Rockbox iPod classic development profile", () => { + test("resolves the embedded 320x240 demo", () => { + const plan = resolveRockboxBuildPlan(manifest); + expect(plan.target).toEqual({ + id: ROCKBOX_IPOD_CLASSIC_TARGET_ID, + hostAbi: ROCKBOX_IPOD_CLASSIC_HOST_ABI, + }); + expect(plan.viewport.logical).toEqual([320, 240]); + expect(plan.features["input.buttons"]).toBe(true); + expect(plan.features["text.glyphs.baked"]).toBe(true); + }); + + test("rejects a non-native logical viewport", () => { + const changed = structuredClone(manifest); + changed.app.viewport.fixed.logical = [176, 132]; + expect(() => resolveRockboxBuildPlan(changed)).toThrow(); + }); +}); diff --git a/tools/rockbox-profile.ts b/tools/rockbox-profile.ts new file mode 100644 index 000000000..964d23d6d --- /dev/null +++ b/tools/rockbox-profile.ts @@ -0,0 +1,46 @@ +import { + POCKET_CAPABILITIES, + definePlatformContractRegistry, + defineTargetRegistry, +} from "../contracts/spec/platforms.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; + +export const ROCKBOX_IPOD_CLASSIC_TARGET_ID = "rockbox-ipod-classic-dev"; +export const ROCKBOX_IPOD_CLASSIC_HOST_ABI = 9; +export const ROCKBOX_IPOD_CLASSIC_VIEWPORT = [320, 240] as const; + +/** Development-only until the .rock plugin passes physical click-wheel tests. */ +export const ROCKBOX_IPOD_CLASSIC_CONTRACTS = definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [ROCKBOX_IPOD_CLASSIC_TARGET_ID]: { + hostAbi: ROCKBOX_IPOD_CLASSIC_HOST_ABI, + platform: "rockbox-ipod-classic", + form: "takeover", + display: { + physicalViewport: ROCKBOX_IPOD_CLASSIC_VIEWPORT, + logicalViewports: [ROCKBOX_IPOD_CLASSIC_VIEWPORT], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: ["input.buttons", "text.glyphs.baked"], + }, + }), +); + +export function resolveRockboxBuildPlan(input: unknown): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target: ROCKBOX_IPOD_CLASSIC_TARGET_ID }, + ROCKBOX_IPOD_CLASSIC_CONTRACTS, + ); + if (!resolution.ok) { + throw new Error( + `pocket rockbox: manifest did not resolve: ${resolution.diagnostics + .map((diagnostic) => `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/rockbox.ts b/tools/rockbox.ts new file mode 100644 index 000000000..9fc5b7029 --- /dev/null +++ b/tools/rockbox.ts @@ -0,0 +1,217 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { + ROCKBOX_IPOD_CLASSIC_TARGET_ID, + resolveRockboxBuildPlan, +} from "./rockbox-profile.ts"; + +const repository = fileURLToPath(new URL("..", import.meta.url)); +const hostDirectory = join(repository, "hosts/rockbox"); +const outputDirectory = join(repository, "dist/rockbox"); +const planPath = join(repository, ".pocket/rockbox-ipod-classic-dev/plan.json"); +const embeddedPath = join(outputDirectory, "generated/app_data.c"); +const defaultManifest = join(hostDirectory, "demo.pocket.json"); +const targetJson = join(hostDirectory, "targets/armv5te-rockbox-eabi.json"); +const coreDirectory = join(repository, "engine/symbian"); +const coreArchive = join( + coreDirectory, + "target/armv5te-rockbox-eabi/release/libpocketjs_symbian_core.a", +); +const quickJsRevision = "ba5bdd0dc013518768e76cd9e05cd30ed53dd35b"; +const quickJsCheckout = join(outputDirectory, "quickjs-rs"); +const quickJsPatch = join(repository, "tools/rockbox/quickjs.patch"); +const command = Bun.argv[2] ?? "doctor"; + +function run(executable: string, args: readonly string[], cwd = repository): void { + const result = Bun.spawnSync({ + cmd: [executable, ...args], + cwd, + stdout: "inherit", + stderr: "inherit", + env: process.env, + }); + if (result.exitCode !== 0) { + throw new Error(`${executable} ${args.join(" ")} failed (${result.exitCode})`); + } +} + +function quickJsSource(): string | undefined { + const configured = process.env.POCKETJS_QUICKJS_DIR; + if (configured && existsSync(join(configured, "quickjs.c"))) return configured; + const nested = join(quickJsCheckout, "libquickjs-sys/embed/quickjs"); + return existsSync(join(nested, "quickjs.c")) ? nested : undefined; +} + +function doctor(): void { + const checks = [ + ["Bun", Bun.which("bun")], + ["Rustup", Bun.which("rustup")], + ["C compiler", Bun.which("cc")], + ["Rockbox ARM compiler", Bun.which("arm-elf-eabi-gcc") ?? Bun.which("arm-none-eabi-gcc")], + ] as const; + for (const [label, path] of checks) { + console.log(`${path ? "[ok]" : "[missing]"} ${label}: ${path ?? "not on PATH"}`); + } + const source = process.env.ROCKBOX_SOURCE; + console.log(`${source && existsSync(join(source, "tools/configure")) ? "[ok]" : "[missing]"} Rockbox source: ${source ?? "set ROCKBOX_SOURCE"}`); + const quickjs = quickJsSource(); + console.log(`${quickjs ? "[ok]" : "[missing]"} pinned QuickJS: ${quickjs ?? "run bun rockbox bootstrap"}`); + if (checks.some(([, path]) => !path) || !source || !quickjs) process.exitCode = 1; +} + +function bootstrap(): void { + if (!existsSync(join(quickJsCheckout, ".git"))) { + mkdirSync(outputDirectory, { recursive: true }); + run("git", ["clone", "--filter=blob:none", "--no-checkout", + "https://github.com/pocket-stack/quickjs-rs.git", quickJsCheckout]); + } + run("git", ["-C", quickJsCheckout, "checkout", "--detach", quickJsRevision]); + const reverse = Bun.spawnSync({ + cmd: ["git", "-C", quickJsCheckout, "apply", "--unidiff-zero", + "--reverse", "--check", quickJsPatch], + stdout: "ignore", + stderr: "ignore", + }); + if (reverse.exitCode !== 0) { + run("git", ["-C", quickJsCheckout, "apply", "--unidiff-zero", quickJsPatch]); + } + console.log(`PocketJS Rockbox: pinned QuickJS ready at ${quickJsCheckout}`); +} + +function currentPlan(manifestPath: string): ResolvedBuildPlan { + return resolveRockboxBuildPlan(JSON.parse(readFileSync(manifestPath, "utf8"))); +} + +function cArray(name: string, bytes: Uint8Array): string { + const rows: string[] = []; + for (let offset = 0; offset < bytes.length; offset += 16) { + rows.push(` ${[...bytes.subarray(offset, offset + 16)] + .map((value) => `0x${value.toString(16).padStart(2, "0")}`).join(", ")},`); + } + return [`const unsigned char ${name}[] = {`, ...rows, "};", + `const unsigned int ${name}_len = ${bytes.length}u;`, ""].join("\n"); +} + +function bundle(manifestPath: string): void { + const plan = currentPlan(manifestPath); + const inputs = extractHostBuildInputs(plan, { + expectedTarget: ROCKBOX_IPOD_CLASSIC_TARGET_ID, + }); + mkdirSync(dirname(planPath), { recursive: true }); + mkdirSync(outputDirectory, { recursive: true }); + mkdirSync(dirname(embeddedPath), { recursive: true }); + writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`); + run(process.execPath, [join(repository, "tools/build.ts"), `--plan=${planPath}`, + `--project-root=${repository}`, `--outdir=${outputDirectory}`]); + const jsPath = join(outputDirectory, `${inputs.appOutput}.js`); + const pakPath = join(outputDirectory, `${inputs.appOutput}.pak`); + if (!existsSync(jsPath) || !existsSync(pakPath)) { + throw new Error("PocketJS compiler did not emit JavaScript and pak artifacts"); + } + writeFileSync(embeddedPath, + cArray("pocket_app_js", readFileSync(jsPath)) + + cArray("pocket_app_pak", readFileSync(pakPath))); + console.log(`PocketJS Rockbox: embedded guest -> ${embeddedPath}`); +} + +function buildCore(): void { + run("cargo", ["build", "--release", "--locked", "--no-default-features", + "--features", "software-only,host-allocator", "--target", targetJson, + "-Z", "json-target-spec", "-Z", "build-std=core,alloc,compiler_builtins", + "-Z", "build-std-features=compiler-builtins-mem"], coreDirectory); + if (!existsSync(coreArchive)) throw new Error(`missing Rust core ${coreArchive}`); +} + +function copySources(stage: string, quickjs: string): void { + rmSync(stage, { recursive: true, force: true }); + mkdirSync(stage, { recursive: true }); + mkdirSync(join(stage, "sys"), { recursive: true }); + for (const name of ["main.c", "input.c", "input.h", "framebuffer.c", "framebuffer.h", + "compat.c", "compat.h", "runtime_port.c", "qjs_config.h", "qjs_quickjs.c", + "qjs_cutils.c", "qjs_libregexp.c", "qjs_libunicode.c", "qjs_dtoa.c", + "pocketjs.make"]) { + copyFileSync(join(hostDirectory, name), join(stage, name)); + } + copyFileSync(join(hostDirectory, "sys/time.h"), join(stage, "sys/time.h")); + for (const name of ["pocket_runtime.c", "pocket_runtime.h", "pocket_spec.h", + "pocket_core.h"]) { + copyFileSync(join(repository, "hosts/iphone2g", name), join(stage, name)); + } + for (const name of ["quickjs.c", "quickjs.h", "quickjs-atom.h", "quickjs-opcode.h", + "cutils.c", "cutils.h", "list.h", "libregexp.c", "libregexp.h", "libregexp-opcode.h", + "libunicode.c", "libunicode.h", "libunicode-table.h", "dtoa.c", "dtoa.h"]) { + copyFileSync(join(quickjs, name), join(stage, name)); + } + copyFileSync(embeddedPath, join(stage, "app_data.c")); + copyFileSync(coreArchive, join(stage, "libpocketjs_rockbox_core.a")); +} + +function configureRockbox(source: string, buildDirectory: string, simulator: boolean): void { + mkdirSync(buildDirectory, { recursive: true }); + if (existsSync(join(buildDirectory, "Makefile"))) return; + const args = ["--target=ipod6g", `--type=${simulator ? "s" : "n"}`, "--no-ccache"]; + if (!simulator && !Bun.which("arm-elf-eabi-gcc") && Bun.which("arm-none-eabi-gcc")) { + args.push("--compiler-prefix=arm-none-eabi-"); + } + run(join(source, "tools/configure"), args, buildDirectory); +} + +function build(manifestPath: string, simulator: boolean): void { + const source = resolve(process.env.ROCKBOX_SOURCE ?? ""); + if (!source || !existsSync(join(source, "tools/configure"))) { + throw new Error("set ROCKBOX_SOURCE to a Rockbox source checkout"); + } + if (!quickJsSource()) bootstrap(); + bundle(manifestPath); + buildCore(); + const quickjs = quickJsSource(); + if (!quickjs) throw new Error("QuickJS bootstrap failed"); + const stage = join(source, "apps/plugins/pocketjs"); + copySources(stage, quickjs); + const buildDirectory = resolve(process.env.ROCKBOX_BUILD ?? + join(outputDirectory, simulator ? "rockbox-sim-build" : "rockbox-ipod6g-build")); + configureRockbox(source, buildDirectory, simulator); + const makeTarget = join(buildDirectory, "apps/plugins/pocketjs/pocketjs.rock"); + run("make", ["-j", String(Math.max(1, navigator.hardwareConcurrency ?? 1)), + "SELECTED_PLUGINS_SRC=", `SELECTED_PLUGINS_SUBDIRS=${stage}`, + makeTarget], buildDirectory); + const artifact = join(buildDirectory, "apps/plugins/pocketjs/pocketjs.rock"); + if (!existsSync(artifact)) throw new Error(`Rockbox did not emit ${artifact}`); + mkdirSync(outputDirectory, { recursive: true }); + const destination = join(outputDirectory, simulator ? "pocketjs-sim.rock" : "pocketjs-ipod6g.rock"); + copyFileSync(artifact, destination); + console.log(`PocketJS Rockbox: ${destination} (${statSync(destination).size} bytes)`); +} + +const manifestArg = Bun.argv.find((value) => value.startsWith("--manifest=")); +const manifestPath = resolve(manifestArg?.slice("--manifest=".length) ?? defaultManifest); +if (command === "doctor") doctor(); +else if (command === "bootstrap") bootstrap(); +else if (command === "bundle") bundle(manifestPath); +else if (command === "build") build(manifestPath, false); +else if (command === "sim") build(manifestPath, true); +else if (command === "test") { + mkdirSync(outputDirectory, { recursive: true }); + run("cc", ["-std=c11", "-Wall", "-Wextra", "-Werror", "-Ihosts/iphone2g", + "hosts/rockbox/framebuffer.c", "hosts/rockbox/input.c", + "hosts/rockbox/tests/platform_test.c", "-o", join(outputDirectory, "platform-test")]); + run(join(outputDirectory, "platform-test"), []); + run("bun", ["test", "tests/rockbox-profile.test.ts"]); +} else if (command === "clean") { + rmSync(join(outputDirectory, "generated"), { recursive: true, force: true }); + rmSync(planPath, { force: true }); +} else { + console.error("usage: bun rockbox [--manifest=path]"); + process.exit(1); +} diff --git a/tools/rockbox/quickjs.patch b/tools/rockbox/quickjs.patch new file mode 100644 index 000000000..051020ca6 --- /dev/null +++ b/tools/rockbox/quickjs.patch @@ -0,0 +1,33 @@ +diff --git a/libquickjs-sys/embed/quickjs/quickjs.c b/libquickjs-sys/embed/quickjs/quickjs.c +index 39e334c..afc8bec 100644 +--- a/libquickjs-sys/embed/quickjs/quickjs.c ++++ b/libquickjs-sys/embed/quickjs/quickjs.c +@@ -100 +100,2 @@ _Static_assert(_Alignof(JSValue) == 8, "Vita JSValue must be 8-byte aligned"); +-#if defined(__PSP__) || defined(__vita__) ++#if defined(__PSP__) || defined(__vita__) || \ ++ defined(POCKETJS_NO_ATOMICS) +@@ -2168 +2169,4 @@ static size_t js_def_malloc_usable_size(const void *ptr) +-#if defined(__APPLE__) ++#if defined(POCKETJS_NO_MALLOC_USABLE_SIZE) ++ (void)ptr; ++ return 0; ++#elif defined(__APPLE__) +@@ -7472 +7476,2 @@ static int find_line_num(JSContext *ctx, JSFunctionBytecode *b, +- int new_line_num, line_num, pc, v, ret, new_col_num, col_num; ++ int new_line_num, line_num, pc, ret, new_col_num, col_num; ++ int32_t v; +@@ -44892 +44897,2 @@ static JSValue js_parseInt(JSContext *ctx, JSValueConst this_val, +- int radix, flags; ++ int32_t radix; ++ int flags; +@@ -47305 +47311,2 @@ static int getTimezoneOffset(int64_t time) +-#if defined(__PSP__) || defined(__vita__) ++#if defined(__PSP__) || defined(__vita__) || \ ++ defined(POCKETJS_FIXED_TIMEZONE) +@@ -53699 +53706 @@ static __exception int remainingElementsCount_add(JSContext *ctx, +- int remainingElementsCount; ++ int32_t remainingElementsCount; +@@ -53730 +53737,2 @@ static JSValue js_promise_all_resolve_element(JSContext *ctx, +- int is_zero, index; ++ int is_zero; ++ int32_t index; From 3c823ac89a11092c89f5c3071a045a498946d55a Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 17:59:00 -0400 Subject: [PATCH 02/19] fix(rockbox): allocate QuickJS heap from audio buffer --- hosts/rockbox/main.c | 10 +++++++--- tests/rockbox-profile.test.ts | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/hosts/rockbox/main.c b/hosts/rockbox/main.c index 130ce5b4a..099cb594f 100644 --- a/hosts/rockbox/main.c +++ b/hosts/rockbox/main.c @@ -52,10 +52,14 @@ enum plugin_status plugin_start(const void *parameter) { bool runtime_ready = false; (void)parameter; - heap = rb->plugin_get_buffer(&heap_size); - if (heap == 0 || heap_size < 256u * 1024u || + /* The linked plugin leaves less than 1 MiB in PLUGIN_BUFFER_SIZE, which is + not enough for QuickJS to parse the bundled Solid application. Rockbox + exposes the much larger audio buffer to plugins that stop playback. */ + rb->audio_stop(); + heap = rb->plugin_get_audio_buffer(&heap_size); + if (heap == 0 || heap_size < 2u * 1024u * 1024u || init_memory_pool(heap_size, heap) == (size_t)-1) { - rb->splash(HZ * 3, "PocketJS: not enough plugin memory"); + rb->splash(HZ * 3, "PocketJS: not enough audio memory"); return PLUGIN_ERROR; } diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 66fab7aba..f45ae44d3 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -11,6 +11,7 @@ const root = join(import.meta.dir, ".."); const manifest = JSON.parse( readFileSync(join(root, "hosts/rockbox/demo.pocket.json"), "utf8"), ); +const nativeHost = readFileSync(join(root, "hosts/rockbox/main.c"), "utf8"); describe("Rockbox iPod classic development profile", () => { test("resolves the embedded 320x240 demo", () => { @@ -29,4 +30,10 @@ describe("Rockbox iPod classic development profile", () => { changed.app.viewport.fixed.logical = [176, 132]; expect(() => resolveRockboxBuildPlan(changed)).toThrow(); }); + + test("uses Rockbox's audio buffer for the QuickJS heap", () => { + expect(nativeHost).toContain("rb->audio_stop();"); + expect(nativeHost).toContain("rb->plugin_get_audio_buffer(&heap_size)"); + expect(nativeHost).not.toContain("rb->plugin_get_buffer(&heap_size)"); + }); }); From 41d891c7105f3513906e827f582a73891e7f56f4 Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 18:16:57 -0400 Subject: [PATCH 03/19] fix(rockbox): run QuickJS on a dedicated stack --- hosts/iphone2g/pocket_runtime.c | 19 ++++++- hosts/rockbox/main.c | 91 +++++++++++++++++++++++++-------- hosts/rockbox/runtime_port.c | 3 +- tests/rockbox-profile.test.ts | 18 +++++-- 4 files changed, 103 insertions(+), 28 deletions(-) diff --git a/hosts/iphone2g/pocket_runtime.c b/hosts/iphone2g/pocket_runtime.c index 4377c3129..53ef4af19 100644 --- a/hosts/iphone2g/pocket_runtime.c +++ b/hosts/iphone2g/pocket_runtime.c @@ -88,15 +88,30 @@ static void set_error(const char *message) { static void take_exception(JSContext *exception_context) { JSValue exception = JS_GetException(exception_context); + JSValue detail = JS_UNDEFINED; size_t length = 0; - const char *message = JS_ToCStringLen2(exception_context, &length, exception, 0); + const char *message = 0; + if (JS_IsObject(exception)) { + detail = JS_GetPropertyStr(exception_context, exception, "message"); + if (!JS_IsException(detail) && !JS_IsUndefined(detail)) { + message = JS_ToCStringLen2(exception_context, &length, detail, 0); + } + } + if (message == 0 && !JS_IsException(detail)) { + message = JS_ToCStringLen2(exception_context, &length, exception, 0); + } if (message != 0) { size_t copy_length = length < sizeof(last_error) - 1 ? length : sizeof(last_error) - 1; memcpy(last_error, message, copy_length); last_error[copy_length] = '\0'; JS_FreeCString(exception_context, message); } else { - set_error("QuickJS exception"); + if (JS_IsNull(exception)) set_error("QuickJS out of memory"); + else if (JS_IsUninitialized(exception)) set_error("QuickJS missing exception"); + else set_error("QuickJS exception"); + } + if (!JS_IsUndefined(detail) && !JS_IsException(detail)) { + JS_FreeValue(exception_context, detail); } JS_FreeValue(exception_context, exception); } diff --git a/hosts/rockbox/main.c b/hosts/rockbox/main.c index 099cb594f..023e3c809 100644 --- a/hosts/rockbox/main.c +++ b/hosts/rockbox/main.c @@ -18,7 +18,12 @@ extern const unsigned int pocket_app_js_len; extern const unsigned char pocket_app_pak[]; extern const unsigned int pocket_app_pak_len; +#define POCKETJS_RUNTIME_STACK_SIZE (16u * 1024u * 1024u) + static fb_data display[LCD_WIDTH * LCD_HEIGHT] MEM_ALIGN_ATTR; +static int boot_stage; +static size_t runtime_heap_size; +static enum plugin_status runtime_status; static const RockboxInputCodes input_codes = { .select = BUTTON_SELECT, @@ -36,38 +41,26 @@ void *pocket_host_realloc(void *pointer, size_t size) { return tlsf_realloc(pointer, size); } void pocket_host_free(void *pointer) { tlsf_free(pointer); } +void pocket_host_boot_stage(int stage) { boot_stage = stage; } static enum plugin_status show_runtime_error(void) { const char *message = pocket_runtime_error(); - rb->splashf(HZ * 4, "PocketJS: %s", message && *message ? message : "runtime error"); + rb->splashf( + HZ * 8, + "PJS S%d H%luK: %s", + boot_stage, + (unsigned long)(runtime_heap_size / 1024u), + message && *message ? message : "runtime error" + ); return PLUGIN_ERROR; } -enum plugin_status plugin_start(const void *parameter) { +static void pocketjs_runtime_thread(void) { enum plugin_status status = PLUGIN_OK; - size_t heap_size = 0; - void *heap; int pending_event = BUTTON_NONE; int cadence = 0; bool runtime_ready = false; - (void)parameter; - /* The linked plugin leaves less than 1 MiB in PLUGIN_BUFFER_SIZE, which is - not enough for QuickJS to parse the bundled Solid application. Rockbox - exposes the much larger audio buffer to plugins that stop playback. */ - rb->audio_stop(); - heap = rb->plugin_get_audio_buffer(&heap_size); - if (heap == 0 || heap_size < 2u * 1024u * 1024u || - init_memory_pool(heap_size, heap) == (size_t)-1) { - rb->splash(HZ * 3, "PocketJS: not enough audio memory"); - return PLUGIN_ERROR; - } - -#ifdef HAVE_ADJUSTABLE_CPU_FREQ - rb->cpu_boost(true); -#endif - rb->backlight_on(); - if (!pocket_runtime_boot( pocket_app_js, pocket_app_js_len, @@ -122,8 +115,62 @@ enum plugin_status plugin_start(const void *parameter) { cleanup: if (runtime_ready) pocket_runtime_shutdown(); + runtime_status = status; +} + +enum plugin_status plugin_start(const void *parameter) { + size_t audio_size = 0; + size_t heap_size; + unsigned int thread_id; + unsigned char *audio_buffer; + unsigned char *heap; + + (void)parameter; + /* QuickJS source evaluation needs substantially more native stack than the + 8 KiB Rockbox main thread provides. Reserve a stable 16 MiB execution + stack from the audio buffer; the rest is the PocketJS allocation heap. */ + rb->audio_stop(); + audio_buffer = rb->plugin_get_audio_buffer(&audio_size); + if (audio_buffer == 0 || + audio_size < POCKETJS_RUNTIME_STACK_SIZE + 2u * 1024u * 1024u) { + rb->splash(HZ * 3, "PocketJS: not enough audio memory"); + return PLUGIN_ERROR; + } + + heap = audio_buffer + POCKETJS_RUNTIME_STACK_SIZE; + heap_size = audio_size - POCKETJS_RUNTIME_STACK_SIZE; + runtime_heap_size = heap_size; + if (init_memory_pool(heap_size, heap) == (size_t)-1) { + rb->splash(HZ * 3, "PocketJS: heap init failed"); + return PLUGIN_ERROR; + } + +#ifdef HAVE_ADJUSTABLE_CPU_FREQ + rb->cpu_boost(true); +#endif + rb->backlight_on(); + + runtime_status = PLUGIN_ERROR; + thread_id = rb->create_thread( + pocketjs_runtime_thread, + audio_buffer, + POCKETJS_RUNTIME_STACK_SIZE, + 0, + "pocketjs" + IF_PRIO(, PRIORITY_USER_INTERFACE) + IF_COP(, CPU) + ); + if (thread_id == 0) { + rb->splash(HZ * 3, "PocketJS: thread creation failed"); +#ifdef HAVE_ADJUSTABLE_CPU_FREQ + rb->cpu_boost(false); +#endif + return PLUGIN_ERROR; + } + + rb->thread_wait(thread_id); #ifdef HAVE_ADJUSTABLE_CPU_FREQ rb->cpu_boost(false); #endif - return status; + return runtime_status; } diff --git a/hosts/rockbox/runtime_port.c b/hosts/rockbox/runtime_port.c index 677096a2b..fca0e3284 100644 --- a/hosts/rockbox/runtime_port.c +++ b/hosts/rockbox/runtime_port.c @@ -1,6 +1,7 @@ #define POCKETJS_TARGET_ID "rockbox-ipod-classic-dev" #define POCKETJS_HOST_ABI 9 #define POCKET_RASTER_DENSITY 1 -#define POCKET_RUNTIME_JS_STACK_SIZE 65536 +#define POCKET_RUNTIME_JS_STACK_SIZE (8 * 1024 * 1024) +#define POCKET_RUNTIME_REPORT_BOOT_STAGE 1 #include "compat.h" #include "pocket_runtime.c" diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index f45ae44d3..2ea227ba7 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -12,6 +12,10 @@ const manifest = JSON.parse( readFileSync(join(root, "hosts/rockbox/demo.pocket.json"), "utf8"), ); const nativeHost = readFileSync(join(root, "hosts/rockbox/main.c"), "utf8"); +const runtimePort = readFileSync( + join(root, "hosts/rockbox/runtime_port.c"), + "utf8", +); describe("Rockbox iPod classic development profile", () => { test("resolves the embedded 320x240 demo", () => { @@ -31,9 +35,17 @@ describe("Rockbox iPod classic development profile", () => { expect(() => resolveRockboxBuildPlan(changed)).toThrow(); }); - test("uses Rockbox's audio buffer for the QuickJS heap", () => { + test("reserves a 16 MiB runtime stack before the QuickJS heap", () => { expect(nativeHost).toContain("rb->audio_stop();"); - expect(nativeHost).toContain("rb->plugin_get_audio_buffer(&heap_size)"); - expect(nativeHost).not.toContain("rb->plugin_get_buffer(&heap_size)"); + expect(nativeHost).toContain("rb->plugin_get_audio_buffer(&audio_size)"); + expect(nativeHost).toContain( + "#define POCKETJS_RUNTIME_STACK_SIZE (16u * 1024u * 1024u)", + ); + expect(nativeHost).toContain("heap = audio_buffer + POCKETJS_RUNTIME_STACK_SIZE"); + expect(nativeHost).toContain("rb->create_thread("); + expect(nativeHost).toContain("POCKETJS_RUNTIME_STACK_SIZE,"); + expect(runtimePort).toContain( + "#define POCKET_RUNTIME_JS_STACK_SIZE (8 * 1024 * 1024)", + ); }); }); From b6c8bb3ea828dd6a625b83e7bcbf97ccd495f645 Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 18:29:22 -0400 Subject: [PATCH 04/19] feat(rockbox): add hardware acceptance pages --- hosts/rockbox/demo/contacts-page.tsx | 72 ++++++++++++++++++++ hosts/rockbox/demo/input-test-page.tsx | 87 ++++++++++++++++++++++++ hosts/rockbox/demo/main.tsx | 71 ++++++++++---------- hosts/rockbox/demo/standard-page.tsx | 93 ++++++++++++++++++++++++++ tests/rockbox-profile.test.ts | 39 +++++++++++ 5 files changed, 328 insertions(+), 34 deletions(-) create mode 100644 hosts/rockbox/demo/contacts-page.tsx create mode 100644 hosts/rockbox/demo/input-test-page.tsx create mode 100644 hosts/rockbox/demo/standard-page.tsx diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx new file mode 100644 index 000000000..fd866d8ed --- /dev/null +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -0,0 +1,72 @@ +import { createMemo, createSignal, onMount } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { + VirtualList, + type VirtualListHandle, +} from "@pocketjs/framework/virtual-list"; + +const CONTACT_COUNT = 10_000; +const ROW_HEIGHT = 30; +const LIST_HEIGHT = 204; +const SURNAMES = [ + "Adams", "Bennett", "Carter", "Dawson", "Ellis", "Foster", "Garcia", + "Hayes", "Irwin", "Jordan", "Keller", "Lewis", "Morris", "Nelson", + "Owens", "Parker", "Quinn", "Reed", "Sullivan", "Turner", "Underwood", + "Vaughn", "Walker", "Xavier", "Young", "Zimmerman", +] as const; +const GIVEN_NAMES = [ + "Avery", "Chloe", "Elliot", "Harper", "Jamie", "Morgan", "Riley", "Taylor", +] as const; + +function contact(index: number) { + const surname = SURNAMES[Math.floor(index * SURNAMES.length / CONTACT_COUNT)]; + const given = GIVEN_NAMES[(index * 5 + surname.length) % GIVEN_NAMES.length]; + return { + given, + surname, + ordinal: String(index + 1).padStart(5, "0"), + }; +} + +export default function ContactsPage() { + const [selected, setSelected] = createSignal(0); + let list: VirtualListHandle | undefined; + const selectedContact = createMemo(() => contact(selected())); + + onMount(() => list?.focusRow(0)); + + const row = (index: number) => { + const item = contact(index); + return ( + + {item.given} + {item.surname} + + {item.ordinal} + + + ); + }; + + return ( + + + All Contacts + + (list = handle)} + /> + + + {selectedContact().given} {selectedContact().surname} · {selectedContact().ordinal} + + + + ); +} diff --git a/hosts/rockbox/demo/input-test-page.tsx b/hosts/rockbox/demo/input-test-page.tsx new file mode 100644 index 000000000..3e01c60ee --- /dev/null +++ b/hosts/rockbox/demo/input-test-page.tsx @@ -0,0 +1,87 @@ +import { createSignal } from "solid-js"; +import { Text, View } from "@pocketjs/framework/components"; +import { BTN } from "@pocketjs/framework/input"; +import { onFrame } from "@pocketjs/framework/lifecycle"; + +const INPUTS = [ + [BTN.TRIANGLE, "MENU"], + [BTN.LEFT, "LEFT"], + [BTN.CIRCLE, "SELECT"], + [BTN.RIGHT, "RIGHT"], + [BTN.START, "PLAY"], + [BTN.UP, "WHEEL -"], + [BTN.DOWN, "WHEEL +"], +] as const; + +function InputPill(props: { + label: string; + mask: number; + active: (mask: number) => boolean; +}) { + return ( + + + {props.label} + + + ); +} + +export default function InputTestPage() { + const [held, setHeld] = createSignal(0); + const [flash, setFlash] = createSignal(0); + const [lastInput, setLastInput] = createSignal("NONE"); + const [eventCount, setEventCount] = createSignal(0); + let previous = 0; + let flashFrames = 0; + + onFrame((buttons) => { + const pressed = buttons & ~previous; + previous = buttons; + setHeld(buttons); + if (pressed !== 0) { + const names = INPUTS + .filter(([mask]) => (pressed & mask) !== 0) + .map(([, label]) => label); + setLastInput(names.join(" + ")); + setEventCount((value) => value + names.length); + setFlash(pressed); + flashFrames = 10; + } else if (flashFrames > 0) { + flashFrames -= 1; + if (flashFrames === 0) setFlash(0); + } + }); + + const active = (mask: number) => ((held() | flash()) & mask) !== 0; + + return ( + + Hardware Input Test + Green means held or recently pulsed + + + + {INPUTS.map(([mask, label]) => ( + + ))} + + + + + + LAST EDGE + {lastInput()} + + + EVENTS + {eventCount()} + + + + ); +} diff --git a/hosts/rockbox/demo/main.tsx b/hosts/rockbox/demo/main.tsx index dd5450e12..5687ca866 100644 --- a/hosts/rockbox/demo/main.tsx +++ b/hosts/rockbox/demo/main.tsx @@ -1,46 +1,49 @@ -import { createSignal } from "solid-js"; -import { mount } from "@pocketjs/framework"; +import { Show, createSignal } from "solid-js"; +import { mount } from "@pocketjs/framework/solid"; import { Text, View } from "@pocketjs/framework/components"; import { BTN } from "@pocketjs/framework/input"; import { onButtonPress } from "@pocketjs/framework/lifecycle"; +import ContactsPage from "./contacts-page.tsx"; +import InputTestPage from "./input-test-page.tsx"; +import StandardPage from "./standard-page.tsx"; -function Demo() { - const [selection, setSelection] = createSignal(0); - const [lastInput, setLastInput] = createSignal("READY"); - const items = ["PocketJS", "Rockbox", "iPod classic"] as const; +const PAGE_COUNT = 3; +const PAGE_LABELS = ["DEMO", "INPUT", "CONTACTS"] as const; - onButtonPress(BTN.UP, () => { - setSelection((value) => (value + items.length - 1) % items.length); - setLastInput("WHEEL BACK"); - }); - onButtonPress(BTN.DOWN, () => { - setSelection((value) => (value + 1) % items.length); - setLastInput("WHEEL FORWARD"); +function RockboxDemo() { + const [page, setPage] = createSignal(0); + + onButtonPress(BTN.LEFT | BTN.RIGHT, (pressed, buttons) => { + if ((buttons & BTN.CIRCLE) === 0) return; + if ((pressed & BTN.LEFT) !== 0) { + setPage((value) => (value + PAGE_COUNT - 1) % PAGE_COUNT); + } else if ((pressed & BTN.RIGHT) !== 0) { + setPage((value) => (value + 1) % PAGE_COUNT); + } }); - onButtonPress(BTN.LEFT, () => setLastInput("LEFT")); - onButtonPress(BTN.RIGHT, () => setLastInput("RIGHT")); - onButtonPress(BTN.CIRCLE, () => setLastInput("SELECT")); - onButtonPress(BTN.TRIANGLE, () => setLastInput("MENU")); - onButtonPress(BTN.START, () => setLastInput("PLAY/PAUSE")); return ( - - PocketJS on Rockbox - iPod classic 6G / 7G - - {items.map((item, index) => ( - - {item} - - ))} - - LAST INPUT - {lastInput()} - Hold MENU to exit + + + + + + + + + + + + + + {page() + 1}/{PAGE_COUNT} {PAGE_LABELS[page()]} + + + + SELECT + LEFT / RIGHT + ); } -mount(() => ); +mount(() => ); diff --git a/hosts/rockbox/demo/standard-page.tsx b/hosts/rockbox/demo/standard-page.tsx new file mode 100644 index 000000000..bf31d955f --- /dev/null +++ b/hosts/rockbox/demo/standard-page.tsx @@ -0,0 +1,93 @@ +import { Show, createSignal, onMount } from "solid-js"; +import { animate } from "@pocketjs/framework/animation"; +import { TICKS_PER_SECOND } from "@pocketjs/framework/clock"; +import { + Image, + Text, + View, + type NodeMirror, +} from "@pocketjs/framework/components"; +import { createSpriteAnimation } from "@pocketjs/framework/lifecycle"; +import { frameworkName } from "@pocketjs/framework/solid"; + +const SPINNER_FRAMES = [ + "spinner-00.svg", + "spinner-01.svg", + "spinner-02.svg", + "spinner-03.svg", + "spinner-04.svg", + "spinner-05.svg", + "spinner-06.svg", + "spinner-07.svg", +] as const; + +function Stat(props: { label: string; value: string; cls: string }) { + return ( + + {props.value} + {props.label} + + ); +} + +/** The official PocketJS Hero demo, fixed to the iPod's 320x240 viewport. */ +export default function StandardPage() { + const [count, setCount] = createSignal(0); + const spinner = createSpriteAnimation(SPINNER_FRAMES, { frameStep: 3 }); + let underline: NodeMirror | undefined; + + onMount(() => { + if (underline) animate(underline, "width", 196, { + dur: 700, + easing: "out", + delay: 150, + }); + }); + + return ( + + + + + + PocketJS + {frameworkName()} + QUICKJS + + + + + + + + + + + ONE RUST CORE · ONE JSX APP + + JSX at 60 FPS. + + + + Flexbox, springs and baked type on iPod classic. + + + + setCount((value) => value + 1)} + > + Press Select + + Count: {count()} + 3}> + Reactive. + + + + ); +} diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 2ea227ba7..5a4cd3197 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -16,6 +16,18 @@ const runtimePort = readFileSync( join(root, "hosts/rockbox/runtime_port.c"), "utf8", ); +const demoMain = readFileSync( + join(root, "hosts/rockbox/demo/main.tsx"), + "utf8", +); +const inputPage = readFileSync( + join(root, "hosts/rockbox/demo/input-test-page.tsx"), + "utf8", +); +const contactsPage = readFileSync( + join(root, "hosts/rockbox/demo/contacts-page.tsx"), + "utf8", +); describe("Rockbox iPod classic development profile", () => { test("resolves the embedded 320x240 demo", () => { @@ -48,4 +60,31 @@ describe("Rockbox iPod classic development profile", () => { "#define POCKET_RUNTIME_JS_STACK_SIZE (8 * 1024 * 1024)", ); }); + + test("ships three hardware-switchable acceptance pages", () => { + expect(demoMain).toContain("const PAGE_COUNT = 3"); + expect(demoMain).toContain("buttons & BTN.CIRCLE"); + expect(demoMain).toContain("pressed & BTN.LEFT"); + expect(demoMain).toContain("pressed & BTN.RIGHT"); + expect(demoMain).toContain(""); + expect(demoMain).toContain(""); + expect(demoMain).toContain(""); + }); + + test("covers every iPod input and virtualizes 10,000 contacts", () => { + for (const button of [ + "BTN.TRIANGLE", + "BTN.LEFT", + "BTN.CIRCLE", + "BTN.RIGHT", + "BTN.START", + "BTN.UP", + "BTN.DOWN", + ]) { + expect(inputPage).toContain(button); + } + expect(contactsPage).toContain("const CONTACT_COUNT = 10_000"); + expect(contactsPage).toContain(" Date: Mon, 31 Aug 2026 18:37:47 -0400 Subject: [PATCH 05/19] fix(rockbox): improve contact list navigation --- framework/src/virtual-list.ts | 5 +- hosts/rockbox/demo/contacts-page.tsx | 133 ++++++++++++++++++++------- tests/rockbox-profile.test.ts | 5 +- tests/virtual-list.test.ts | 14 +++ 4 files changed, 123 insertions(+), 34 deletions(-) diff --git a/framework/src/virtual-list.ts b/framework/src/virtual-list.ts index f87b30b17..5fb8dec46 100644 --- a/framework/src/virtual-list.ts +++ b/framework/src/virtual-list.ts @@ -77,6 +77,9 @@ export interface VirtualListProps { /** Rows are Focusable and the d-pad drives a focused index (default). * false: rows are plain views and the d-pad scrolls the im way. */ focusRows?: boolean; + /** Logical pixels added to the scroll target for each held d-pad frame when + * focusRows is false. Default 6. Click wheels commonly use one row. */ + dpadStepPx?: number; onRowPress?: (index: number) => void; /** Fired every frame while the offset is within nearStartPx of the top — * guard with your own loading/hasMore flags (the im convention). */ @@ -229,7 +232,7 @@ export function VirtualList(props: VirtualListProps): SolidJSX.Element { } }); } else { - bindDpadScroll(scroller, { active }); + bindDpadScroll(scroller, { active, stepPx: props.dpadStepPx }); } }); diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index fd866d8ed..7eba1f1d9 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -1,9 +1,9 @@ -import { createMemo, createSignal, onMount } from "solid-js"; +import { Show, createMemo, createSignal } from "solid-js"; import { Text, View } from "@pocketjs/framework/components"; -import { - VirtualList, - type VirtualListHandle, -} from "@pocketjs/framework/virtual-list"; +import { BTN } from "@pocketjs/framework/input"; +import { createScroller } from "@pocketjs/framework/kinetics"; +import { onButtonPress } from "@pocketjs/framework/lifecycle"; +import { VirtualList } from "@pocketjs/framework/virtual-list"; const CONTACT_COUNT = 10_000; const ROW_HEIGHT = 30; @@ -21,52 +21,121 @@ const GIVEN_NAMES = [ function contact(index: number) { const surname = SURNAMES[Math.floor(index * SURNAMES.length / CONTACT_COUNT)]; const given = GIVEN_NAMES[(index * 5 + surname.length) % GIVEN_NAMES.length]; + const line = String((index * 17 + 31) % 100).padStart(2, "0"); return { given, surname, ordinal: String(index + 1).padStart(5, "0"), + phone: `(415) 555-01${line}`, + email: `${given}@${surname}.com`.toLowerCase(), }; } +function NavigationBar(props: { title: string; back?: boolean }) { + return ( + + {props.title} + + + MENU: Back + + + + ); +} + export default function ContactsPage() { - const [selected, setSelected] = createSignal(0); - let list: VirtualListHandle | undefined; - const selectedContact = createMemo(() => contact(selected())); + const [detailIndex, setDetailIndex] = createSignal(null); + const listScroller = createScroller({ + max: () => CONTACT_COUNT * ROW_HEIGHT - LIST_HEIGHT, + extent: () => LIST_HEIGHT, + }); + const currentIndex = createMemo(() => Math.max( + 0, + Math.min(CONTACT_COUNT - 1, Math.round(listScroller.offset() / ROW_HEIGHT)), + )); + const current = createMemo(() => contact(currentIndex())); + const detail = createMemo(() => contact(detailIndex() ?? currentIndex())); - onMount(() => list?.focusRow(0)); + onButtonPress(BTN.CIRCLE, () => { + if (detailIndex() === null) setDetailIndex(currentIndex()); + }, { latched: true }); + onButtonPress(BTN.TRIANGLE, () => { + if (detailIndex() !== null) setDetailIndex(null); + }, { latched: true }); const row = (index: number) => { const item = contact(index); + const active = () => currentIndex() === index; return ( - - {item.given} - {item.surname} + + {item.given} + + {item.surname} + - {item.ordinal} - + + {item.ordinal} + + ); }; return ( - - - All Contacts - - (list = handle)} - /> - - - {selectedContact().given} {selectedContact().surname} · {selectedContact().ordinal} - - + + + + + + {detail().given} {detail().surname} + Contact {detail().ordinal} of 10,000 + + + + + mobile + {detail().phone} + + + + email + {detail().email} + + + + Press MENU to return to the list + + + + }> + + + + + {current().given} {current().surname} · {current().ordinal} + + + ); } diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 5a4cd3197..debd061be 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -85,6 +85,9 @@ describe("Rockbox iPod classic development profile", () => { } expect(contactsPage).toContain("const CONTACT_COUNT = 10_000"); expect(contactsPage).toContain(" number; + dpadStepPx?: number; onRowPress?: (i: number) => void; focusRows?: boolean; stickToBottom?: boolean; @@ -135,6 +136,7 @@ function mountList(opts: MountOpts = {}): VirtualListHandle { rowHeight: 10, height: 50, overscan: 20, + dpadStepPx: opts.dpadStepPx, focusRows: opts.focusRows, onRowPress: opts.onRowPress, stickToBottom: opts.stickToBottom, @@ -227,6 +229,18 @@ describe("d-pad focus", () => { }); }); +describe("d-pad scrolling", () => { + test("uses the host-tuned held-frame step when rows do not own focus", () => { + const h = mountList({ focusRows: false, dpadStepPx: 30 }); + frame(BTN.DOWN); + expect(h.scroller.intent()).toBe(30); + frame(BTN.DOWN); + expect(h.scroller.intent()).toBe(60); + frame(BTN.UP); + expect(h.scroller.intent()).toBe(30); + }); +}); + describe("touch", () => { test("tap on a row fires the shared onPress path (hit fact names the row)", () => { const pressed: number[] = []; From 29c6287e063687256d2a47dc1127e9dff39360f3 Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 20:29:42 -0400 Subject: [PATCH 06/19] fix(rockbox): refine contact list interaction --- framework/src/virtual-list.ts | 5 +- hosts/rockbox/demo/contacts-page.tsx | 123 ++++++++++++++++--------- hosts/rockbox/demo/input-test-page.tsx | 2 +- hosts/rockbox/demo/main.tsx | 13 +-- hosts/rockbox/demo/standard-page.tsx | 2 +- tests/rockbox-profile.test.ts | 10 +- tests/virtual-list.test.ts | 14 --- 7 files changed, 91 insertions(+), 78 deletions(-) diff --git a/framework/src/virtual-list.ts b/framework/src/virtual-list.ts index 5fb8dec46..f87b30b17 100644 --- a/framework/src/virtual-list.ts +++ b/framework/src/virtual-list.ts @@ -77,9 +77,6 @@ export interface VirtualListProps { /** Rows are Focusable and the d-pad drives a focused index (default). * false: rows are plain views and the d-pad scrolls the im way. */ focusRows?: boolean; - /** Logical pixels added to the scroll target for each held d-pad frame when - * focusRows is false. Default 6. Click wheels commonly use one row. */ - dpadStepPx?: number; onRowPress?: (index: number) => void; /** Fired every frame while the offset is within nearStartPx of the top — * guard with your own loading/hasMore flags (the im convention). */ @@ -232,7 +229,7 @@ export function VirtualList(props: VirtualListProps): SolidJSX.Element { } }); } else { - bindDpadScroll(scroller, { active, stepPx: props.dpadStepPx }); + bindDpadScroll(scroller, { active }); } }); diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index 7eba1f1d9..5afa99401 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -1,8 +1,13 @@ import { Show, createMemo, createSignal } from "solid-js"; -import { Text, View } from "@pocketjs/framework/components"; +import { animate } from "@pocketjs/framework/animation"; +import { + Text, + View, + type NodeMirror, +} from "@pocketjs/framework/components"; import { BTN } from "@pocketjs/framework/input"; import { createScroller } from "@pocketjs/framework/kinetics"; -import { onButtonPress } from "@pocketjs/framework/lifecycle"; +import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; import { VirtualList } from "@pocketjs/framework/virtual-list"; const CONTACT_COUNT = 10_000; @@ -34,7 +39,9 @@ function contact(index: number) { function NavigationBar(props: { title: string; back?: boolean }) { return ( - {props.title} + + {props.title} + MENU: Back @@ -45,28 +52,54 @@ function NavigationBar(props: { title: string; back?: boolean }) { } export default function ContactsPage() { - const [detailIndex, setDetailIndex] = createSignal(null); + const [selectedIndex, setSelectedIndex] = createSignal(0); + const [detailIndex, setDetailIndex] = createSignal(0); + const [detailOpen, setDetailOpen] = createSignal(false); + let listPanel: NodeMirror | undefined; + let detailPanel: NodeMirror | undefined; const listScroller = createScroller({ max: () => CONTACT_COUNT * ROW_HEIGHT - LIST_HEIGHT, extent: () => LIST_HEIGHT, }); - const currentIndex = createMemo(() => Math.max( - 0, - Math.min(CONTACT_COUNT - 1, Math.round(listScroller.offset() / ROW_HEIGHT)), - )); - const current = createMemo(() => contact(currentIndex())); - const detail = createMemo(() => contact(detailIndex() ?? currentIndex())); + const detail = createMemo(() => contact(detailIndex())); + + const moveSelection = (delta: number) => { + const next = Math.max(0, Math.min(CONTACT_COUNT - 1, selectedIndex() + delta)); + if (next === selectedIndex()) return; + setSelectedIndex(next); + const offset = listScroller.offset(); + const rowTop = next * ROW_HEIGHT; + const rowBottom = rowTop + ROW_HEIGHT; + if (rowTop < offset) { + listScroller.scrollTo(rowTop, { immediate: true }); + } else if (rowBottom > offset + LIST_HEIGHT) { + listScroller.scrollTo(rowBottom - LIST_HEIGHT, { immediate: true }); + } + }; + + onFrame((buttons) => { + if (detailOpen()) return; + if ((buttons & BTN.UP) !== 0) moveSelection(-1); + else if ((buttons & BTN.DOWN) !== 0) moveSelection(1); + }); onButtonPress(BTN.CIRCLE, () => { - if (detailIndex() === null) setDetailIndex(currentIndex()); + if (detailOpen()) return; + setDetailIndex(selectedIndex()); + setDetailOpen(true); + if (listPanel) animate(listPanel, "translateX", -64, { dur: 180, easing: "out" }); + if (detailPanel) animate(detailPanel, "translateX", 0, { dur: 180, easing: "out" }); }, { latched: true }); onButtonPress(BTN.TRIANGLE, () => { - if (detailIndex() !== null) setDetailIndex(null); + if (!detailOpen()) return; + setDetailOpen(false); + if (listPanel) animate(listPanel, "translateX", 0, { dur: 180, easing: "out" }); + if (detailPanel) animate(detailPanel, "translateX", 320, { dur: 180, easing: "out" }); }, { latched: true }); const row = (index: number) => { const item = contact(index); - const active = () => currentIndex() === index; + const active = () => selectedIndex() === index; return ( - - - - - {detail().given} {detail().surname} - Contact {detail().ordinal} of 10,000 - - - - - mobile - {detail().phone} - - - - email - {detail().email} - - - - Press MENU to return to the list - - - - }> + (listPanel = node)} + class="absolute left-0 top-0 w-[320] h-[240] bg-white" + > false} renderRow={row} style={{ width: 320 }} /> - - - {current().given} {current().surname} · {current().ordinal} - + + + (detailPanel = node)} + class="absolute left-0 top-0 w-[320] h-[240] flex-col bg-[#c5ccd3]" + style={{ translateX: 320 }} + > + + + + {detail().given} {detail().surname} + Contact {detail().ordinal} of 10,000 + + + + + mobile + {detail().phone} + + + + email + {detail().email} + + + - + ); } diff --git a/hosts/rockbox/demo/input-test-page.tsx b/hosts/rockbox/demo/input-test-page.tsx index 3e01c60ee..b4a994a4b 100644 --- a/hosts/rockbox/demo/input-test-page.tsx +++ b/hosts/rockbox/demo/input-test-page.tsx @@ -60,7 +60,7 @@ export default function InputTestPage() { const active = (mask: number) => ((held() | flash()) & mask) !== 0; return ( - + Hardware Input Test Green means held or recently pulsed diff --git a/hosts/rockbox/demo/main.tsx b/hosts/rockbox/demo/main.tsx index 5687ca866..c486a58fd 100644 --- a/hosts/rockbox/demo/main.tsx +++ b/hosts/rockbox/demo/main.tsx @@ -1,6 +1,6 @@ import { Show, createSignal } from "solid-js"; import { mount } from "@pocketjs/framework/solid"; -import { Text, View } from "@pocketjs/framework/components"; +import { View } from "@pocketjs/framework/components"; import { BTN } from "@pocketjs/framework/input"; import { onButtonPress } from "@pocketjs/framework/lifecycle"; import ContactsPage from "./contacts-page.tsx"; @@ -8,7 +8,6 @@ import InputTestPage from "./input-test-page.tsx"; import StandardPage from "./standard-page.tsx"; const PAGE_COUNT = 3; -const PAGE_LABELS = ["DEMO", "INPUT", "CONTACTS"] as const; function RockboxDemo() { const [page, setPage] = createSignal(0); @@ -23,7 +22,7 @@ function RockboxDemo() { }); return ( - + @@ -34,14 +33,6 @@ function RockboxDemo() { - - - {page() + 1}/{PAGE_COUNT} {PAGE_LABELS[page()]} - - - - SELECT + LEFT / RIGHT - ); } diff --git a/hosts/rockbox/demo/standard-page.tsx b/hosts/rockbox/demo/standard-page.tsx index bf31d955f..37d18dd11 100644 --- a/hosts/rockbox/demo/standard-page.tsx +++ b/hosts/rockbox/demo/standard-page.tsx @@ -45,7 +45,7 @@ export default function StandardPage() { }); return ( - + diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index debd061be..18b123b9a 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -86,8 +86,14 @@ describe("Rockbox iPod classic development profile", () => { expect(contactsPage).toContain("const CONTACT_COUNT = 10_000"); expect(contactsPage).toContain(" number; - dpadStepPx?: number; onRowPress?: (i: number) => void; focusRows?: boolean; stickToBottom?: boolean; @@ -136,7 +135,6 @@ function mountList(opts: MountOpts = {}): VirtualListHandle { rowHeight: 10, height: 50, overscan: 20, - dpadStepPx: opts.dpadStepPx, focusRows: opts.focusRows, onRowPress: opts.onRowPress, stickToBottom: opts.stickToBottom, @@ -229,18 +227,6 @@ describe("d-pad focus", () => { }); }); -describe("d-pad scrolling", () => { - test("uses the host-tuned held-frame step when rows do not own focus", () => { - const h = mountList({ focusRows: false, dpadStepPx: 30 }); - frame(BTN.DOWN); - expect(h.scroller.intent()).toBe(30); - frame(BTN.DOWN); - expect(h.scroller.intent()).toBe(60); - frame(BTN.UP); - expect(h.scroller.intent()).toBe(30); - }); -}); - describe("touch", () => { test("tap on a row fires the shared onPress path (hit fact names the row)", () => { const pressed: number[] = []; From da1b34a6e97f1f405102c15c09d66bd2bb80781c Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 20:38:01 -0400 Subject: [PATCH 07/19] fix(rockbox): refine contact navigation --- hosts/rockbox/demo/contacts-page.tsx | 75 ++++++++++++++++++++-------- tests/rockbox-profile.test.ts | 7 ++- 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index 5afa99401..d0081c242 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -13,6 +13,7 @@ import { VirtualList } from "@pocketjs/framework/virtual-list"; const CONTACT_COUNT = 10_000; const ROW_HEIGHT = 30; const LIST_HEIGHT = 204; +const WHEEL_ACCEL_RESET_FRAMES = 6; const SURNAMES = [ "Adams", "Bennett", "Carter", "Dawson", "Ellis", "Foster", "Garcia", "Hayes", "Irwin", "Jordan", "Keller", "Lewis", "Morris", "Nelson", @@ -38,7 +39,7 @@ function contact(index: number) { function NavigationBar(props: { title: string; back?: boolean }) { return ( - + {props.title} @@ -57,12 +58,21 @@ export default function ContactsPage() { const [detailOpen, setDetailOpen] = createSignal(false); let listPanel: NodeMirror | undefined; let detailPanel: NodeMirror | undefined; + let wheelDirection = 0; + let wheelBurst = 0; + let wheelIdleFrames = WHEEL_ACCEL_RESET_FRAMES; const listScroller = createScroller({ max: () => CONTACT_COUNT * ROW_HEIGHT - LIST_HEIGHT, extent: () => LIST_HEIGHT, }); const detail = createMemo(() => contact(detailIndex())); + const resetWheelAcceleration = () => { + wheelDirection = 0; + wheelBurst = 0; + wheelIdleFrames = WHEEL_ACCEL_RESET_FRAMES; + }; + const moveSelection = (delta: number) => { const next = Math.max(0, Math.min(CONTACT_COUNT - 1, selectedIndex() + delta)); if (next === selectedIndex()) return; @@ -77,24 +87,43 @@ export default function ContactsPage() { } }; + const acceleratedWheelDelta = (direction: -1 | 1) => { + if (wheelDirection !== direction || wheelIdleFrames >= WHEEL_ACCEL_RESET_FRAMES) { + wheelDirection = direction; + wheelBurst = 0; + } else { + wheelBurst += 1; + } + wheelIdleFrames = 0; + const multiplier = wheelBurst >= 12 ? 8 : wheelBurst >= 7 ? 4 : wheelBurst >= 3 ? 2 : 1; + return direction * multiplier; + }; + onFrame((buttons) => { if (detailOpen()) return; - if ((buttons & BTN.UP) !== 0) moveSelection(-1); - else if ((buttons & BTN.DOWN) !== 0) moveSelection(1); + if ((buttons & BTN.UP) !== 0) { + moveSelection(acceleratedWheelDelta(-1)); + } else if ((buttons & BTN.DOWN) !== 0) { + moveSelection(acceleratedWheelDelta(1)); + } else { + wheelIdleFrames = Math.min(WHEEL_ACCEL_RESET_FRAMES, wheelIdleFrames + 1); + } }); onButtonPress(BTN.CIRCLE, () => { if (detailOpen()) return; + resetWheelAcceleration(); setDetailIndex(selectedIndex()); setDetailOpen(true); - if (listPanel) animate(listPanel, "translateX", -64, { dur: 180, easing: "out" }); - if (detailPanel) animate(detailPanel, "translateX", 0, { dur: 180, easing: "out" }); + if (listPanel) animate(listPanel, "translateX", -64, { dur: 110, easing: "out" }); + if (detailPanel) animate(detailPanel, "translateX", 0, { dur: 110, easing: "out" }); }, { latched: true }); onButtonPress(BTN.TRIANGLE, () => { if (!detailOpen()) return; + resetWheelAcceleration(); setDetailOpen(false); - if (listPanel) animate(listPanel, "translateX", 0, { dur: 180, easing: "out" }); - if (detailPanel) animate(detailPanel, "translateX", 320, { dur: 180, easing: "out" }); + if (listPanel) animate(listPanel, "translateX", 0, { dur: 110, easing: "out" }); + if (detailPanel) animate(detailPanel, "translateX", 320, { dur: 110, easing: "out" }); }, { latched: true }); const row = (index: number) => { @@ -127,29 +156,30 @@ export default function ContactsPage() { (listPanel = node)} - class="absolute left-0 top-0 w-[320] h-[240] bg-white" + class="absolute left-0 top-0 w-[320] h-[240] bg-white overflow-hidden" > + + false} + renderRow={row} + style={{ width: 320 }} + /> + - false} - renderRow={row} - style={{ width: 320 }} - /> (detailPanel = node)} - class="absolute left-0 top-0 w-[320] h-[240] flex-col bg-[#c5ccd3]" + class="absolute left-0 top-0 w-[320] h-[240] bg-[#c5ccd3] overflow-hidden" style={{ translateX: 320 }} > - - + {detail().given} {detail().surname} Contact {detail().ordinal} of 10,000 @@ -168,6 +198,7 @@ export default function ContactsPage() { + ); diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 18b123b9a..17c7f7319 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -86,11 +86,14 @@ describe("Rockbox iPod classic development profile", () => { expect(contactsPage).toContain("const CONTACT_COUNT = 10_000"); expect(contactsPage).toContain("= 12 ? 8"); expect(contactsPage).toContain("setDetailIndex(selectedIndex())"); expect(contactsPage).toContain("style={{ width: 320 }}"); + expect(contactsPage).toContain('top-[36] w-[320] h-[204]'); + expect(contactsPage).toContain('{ dur: 110, easing: "out" }'); expect(contactsPage).toContain('animate(detailPanel, "translateX", 0'); expect(contactsPage).toContain('animate(detailPanel, "translateX", 320'); expect(demoMain).not.toContain("PAGE_LABELS"); From 4533187575afa76fc4f4ae82ec9918aeaa4a27e5 Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 20:43:58 -0400 Subject: [PATCH 08/19] fix(rockbox): extend wheel acceleration --- hosts/rockbox/demo/contacts-page.tsx | 8 +++++++- tests/rockbox-profile.test.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index d0081c242..6350fa757 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -14,6 +14,8 @@ const CONTACT_COUNT = 10_000; const ROW_HEIGHT = 30; const LIST_HEIGHT = 204; const WHEEL_ACCEL_RESET_FRAMES = 6; +const WHEEL_ACCEL_EVENTS_PER_GEAR = 3; +const WHEEL_ACCEL_MAX_GEAR = 10; const SURNAMES = [ "Adams", "Bennett", "Carter", "Dawson", "Ellis", "Foster", "Garcia", "Hayes", "Irwin", "Jordan", "Keller", "Lewis", "Morris", "Nelson", @@ -95,7 +97,11 @@ export default function ContactsPage() { wheelBurst += 1; } wheelIdleFrames = 0; - const multiplier = wheelBurst >= 12 ? 8 : wheelBurst >= 7 ? 4 : wheelBurst >= 3 ? 2 : 1; + const gear = Math.min( + WHEEL_ACCEL_MAX_GEAR, + Math.floor(wheelBurst / WHEEL_ACCEL_EVENTS_PER_GEAR), + ); + const multiplier = 1 << gear; return direction * multiplier; }; diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 17c7f7319..8eafb3d08 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -89,7 +89,8 @@ describe("Rockbox iPod classic development profile", () => { expect(contactsPage).toContain("rowBottom - LIST_HEIGHT"); expect(contactsPage).toContain("acceleratedWheelDelta(-1)"); expect(contactsPage).toContain("acceleratedWheelDelta(1)"); - expect(contactsPage).toContain("wheelBurst >= 12 ? 8"); + expect(contactsPage).toContain("const WHEEL_ACCEL_MAX_GEAR = 10"); + expect(contactsPage).toContain("const multiplier = 1 << gear"); expect(contactsPage).toContain("setDetailIndex(selectedIndex())"); expect(contactsPage).toContain("style={{ width: 320 }}"); expect(contactsPage).toContain('top-[36] w-[320] h-[204]'); From 9c42cf18846d3a84db7290f17e7136dccc91baf3 Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 20:56:07 -0400 Subject: [PATCH 09/19] fix(rockbox): restore contacts navigation bar --- hosts/rockbox/demo/contacts-page.tsx | 3 ++- tests/rockbox-profile.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index 6350fa757..d96c3320b 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -41,7 +41,7 @@ function contact(index: number) { function NavigationBar(props: { title: string; back?: boolean }) { return ( - + {props.title} @@ -50,6 +50,7 @@ function NavigationBar(props: { title: string; back?: boolean }) { MENU: Back + ); } diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 8eafb3d08..adffeaa1e 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -6,6 +6,7 @@ import { ROCKBOX_IPOD_CLASSIC_TARGET_ID, resolveRockboxBuildPlan, } from "../tools/rockbox-profile.ts"; +import { parseClassLiteral } from "../framework/compiler/tailwind.ts"; const root = join(import.meta.dir, ".."); const manifest = JSON.parse( @@ -100,4 +101,16 @@ describe("Rockbox iPod classic development profile", () => { expect(demoMain).not.toContain("PAGE_LABELS"); expect(demoMain).not.toContain("SELECT + LEFT / RIGHT"); }); + + test("compiles the fixed 320x36 contacts navigation bar", () => { + const navigationClass = contactsPage.match( + /function NavigationBar[\s\S]*? Date: Mon, 31 Aug 2026 21:14:32 -0400 Subject: [PATCH 10/19] feat(rockbox): add elastic contact scrolling --- framework/src/kinetics-core.ts | 98 +++++++++++++++++++++++----- hosts/rockbox/demo/contact-motion.ts | 31 +++++++++ hosts/rockbox/demo/contacts-page.tsx | 39 +++++------ tests/kinetics.test.ts | 72 ++++++++++++++++++++ tests/rockbox-contact-motion.test.ts | 45 +++++++++++++ tests/rockbox-profile.test.ts | 6 +- 6 files changed, 248 insertions(+), 43 deletions(-) create mode 100644 hosts/rockbox/demo/contact-motion.ts create mode 100644 tests/rockbox-contact-motion.test.ts diff --git a/framework/src/kinetics-core.ts b/framework/src/kinetics-core.ts index 110ba8042..0d6e83301 100644 --- a/framework/src/kinetics-core.ts +++ b/framework/src/kinetics-core.ts @@ -4,11 +4,9 @@ // tracking finger-follow (gesture pan feeds drag deltas; out-of-bounds // travel is rubber-banded with the classic iOS curve) // fling exponential decay from the release velocity -// spring edge bounce-back — semi-implicit Euler with the engine's -// Spring constants (K=170, C=26), CARRYING the incoming velocity -// (a fling that crosses an edge keeps its momentum into the -// rubber band; this is the one place "spring with initial -// velocity" is needed, and it lives here, not in the core) +// spring edge bounce-back or retargetable programmatic follow — +// semi-implicit Euler with the engine's Spring constants +// (K=170, C=26), carrying incoming velocity across retargets // chase per-frame ease toward a target — byte-for-byte the apps/im // pump (0.3 of the remaining distance, snap under 0.6 px); the // d-pad / stick-to-bottom mode @@ -76,14 +74,17 @@ export interface Scroller { nudge(delta: number): void; /** Chase an absolute target (focus-follow, stick-to-bottom). */ chaseTo(to: number): void; + /** Spring toward an absolute target. Repeated calls preserve velocity. + * `overshootPx` adds a bounded approach point beyond the final target, + * then returns to the clamped target. */ + springTo(to: number, opts?: { overshootPx?: number }): void; /** Shift offset AND every in-flight anchor by delta after a prepend, so * backfill never moves what the user is looking at (the im rebase). */ rebase(delta: number): void; - /** The position the scroller is heading to: the chase/tween target when - * one is in flight, the current offset otherwise. */ + /** The position the scroller is heading to: the chase/tween/spring final + * target when one is in flight, the current offset otherwise. */ intent(): number; - /** At the end of the range, judged on INTENT: the chase/tween target when - * one is in flight, the position otherwise (the im at-bottom rule). */ + /** At the end of the range, judged on INTENT rather than transient motion. */ isAtEnd(slackPx?: number): boolean; /** Rest position a fling from `v` would reach (for snap functions). */ projectFling(v: number): number; @@ -147,7 +148,11 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S let v = 0; // px per virtual second (fling/spring) let dragPos = 0; // unrubbered drag-space position while tracking let target = 0; // chase target - let springBound = 0; // the edge a spring is heading to + let springBound = 0; // current spring approach/rebound point + let springFinal = 0; // clamped public intent + let springDirection = 0; // approach direction, -1 / 0 / 1 + let springOvershoot = 0; + let springReturning = false; let tweenFrom = 0; let tweenTo = 0; let tweenFrames = 1; @@ -194,6 +199,15 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S return pos + (v0 * TICK_DT * decay) / (1 - decay); } + function startEdgeSpring(bound: number): void { + springBound = bound; + springFinal = bound; + springDirection = 0; + springOvershoot = 0; + springReturning = false; + state = "spring"; + } + return { offset, velocity: () => v, @@ -218,9 +232,8 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S if (state !== "tracking") return; const m = opts.max(); if (pos < 0 || pos > m) { - springBound = pos < 0 ? 0 : m; v = releaseVelocity; - state = "spring"; + startEdgeSpring(pos < 0 ? 0 : m); return; } if (opts.snap) { @@ -249,7 +262,13 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S }, scrollBy(delta: number, o?: { durMs?: number } | { immediate: true }): void { - const base = state === "tween" ? tweenTo : state === "chase" ? target : pos; + const base = state === "tween" + ? tweenTo + : state === "chase" + ? target + : state === "spring" + ? springFinal + : pos; this.scrollTo(base + delta, o); }, @@ -270,17 +289,44 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S state = "chase"; }, + springTo(to: number, o?: { overshootPx?: number }): void { + const final = clampRange(to); + const delta = final - pos; + const direction = delta < 0 ? -1 : delta > 0 ? 1 : 0; + const requested = o?.overshootPx ?? 0; + const approach = Number.isFinite(requested) && requested > 0 ? requested : 0; + + if (state !== "spring" && state !== "fling") v = 0; + springFinal = final; + springDirection = direction; + springOvershoot = direction === 0 ? 0 : approach; + springReturning = false; + springBound = final + direction * springOvershoot; + if (springBound === pos && v === 0) { + state = "idle"; + return; + } + state = "spring"; + }, + rebase(delta: number): void { dragPos += delta; target += delta; springBound += delta; + springFinal += delta; tweenFrom += delta; tweenTo += delta; emit(pos + delta); }, intent(): number { - return state === "chase" ? target : state === "tween" ? tweenTo : pos; + return state === "chase" + ? target + : state === "tween" + ? tweenTo + : state === "spring" + ? springFinal + : pos; }, isAtEnd(slackPx = 1): boolean { @@ -334,8 +380,7 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S const m = opts.max(); if (p < 0 || p > m) { // Carry the momentum into the edge spring mid-tick. - springBound = p < 0 ? 0 : m; - state = "spring"; + startEdgeSpring(p < 0 ? 0 : m); continue; } if (v < FLING_MIN_V && v > -FLING_MIN_V) { @@ -343,18 +388,35 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S return; } } else { - const b = clampRange(springBound); + const b = springBound; const a = SPRING_K * (b - p) - SPRING_C * v; v += a * TICK_DT; p += v * TICK_DT; const dist = b - p; + const crossedApproach = !springReturning && springOvershoot > 0 && ( + springDirection > 0 ? p >= b : p <= b + ); + if (crossedApproach) { + p = b; + v = 0; + springReturning = true; + springBound = springFinal; + continue; + } if ( dist < SPRING_SETTLE_DIST && dist > -SPRING_SETTLE_DIST && v < SPRING_SETTLE_V && v > -SPRING_SETTLE_V ) { - settle(b); + if (!springReturning && springOvershoot > 0) { + p = b; + v = 0; + springReturning = true; + springBound = springFinal; + continue; + } + settle(springFinal); return; } } diff --git a/hosts/rockbox/demo/contact-motion.ts b/hosts/rockbox/demo/contact-motion.ts new file mode 100644 index 000000000..aed6297a5 --- /dev/null +++ b/hosts/rockbox/demo/contact-motion.ts @@ -0,0 +1,31 @@ +export const CONTACT_ROW_HEIGHT = 30; +export const CONTACT_LIST_HEIGHT = 204; +export const CONTACT_UP_ANCHOR_Y = CONTACT_ROW_HEIGHT; +export const CONTACT_DOWN_ANCHOR_Y = + CONTACT_LIST_HEIGHT - 2 * CONTACT_ROW_HEIGHT; +export const CONTACT_SPRING_OVERSHOOT = 12; + +export function wheelMultiplier(burst: number): number { + const gear = Math.min(10, Math.floor(Math.max(0, burst) / 3)); + return 1 << gear; +} + +/** Final list offset required to bring a selected row back into the resting + * band. null means the row can move inside the band without moving the list. */ +export function contactScrollTarget( + selectedIndex: number, + currentIntent: number, + maxOffset: number, +): number | null { + const rowTop = selectedIndex * CONTACT_ROW_HEIGHT; + const screenY = rowTop - currentIntent; + let target: number; + if (screenY > CONTACT_DOWN_ANCHOR_Y) { + target = rowTop - CONTACT_DOWN_ANCHOR_Y; + } else if (screenY < CONTACT_UP_ANCHOR_Y) { + target = rowTop - CONTACT_UP_ANCHOR_Y; + } else { + return null; + } + return Math.max(0, Math.min(maxOffset, target)); +} diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index d96c3320b..8f574c4c8 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -9,13 +9,16 @@ import { BTN } from "@pocketjs/framework/input"; import { createScroller } from "@pocketjs/framework/kinetics"; import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; import { VirtualList } from "@pocketjs/framework/virtual-list"; +import { + CONTACT_LIST_HEIGHT, + CONTACT_ROW_HEIGHT, + CONTACT_SPRING_OVERSHOOT, + contactScrollTarget, + wheelMultiplier, +} from "./contact-motion.ts"; const CONTACT_COUNT = 10_000; -const ROW_HEIGHT = 30; -const LIST_HEIGHT = 204; const WHEEL_ACCEL_RESET_FRAMES = 6; -const WHEEL_ACCEL_EVENTS_PER_GEAR = 3; -const WHEEL_ACCEL_MAX_GEAR = 10; const SURNAMES = [ "Adams", "Bennett", "Carter", "Dawson", "Ellis", "Foster", "Garcia", "Hayes", "Irwin", "Jordan", "Keller", "Lewis", "Morris", "Nelson", @@ -65,8 +68,8 @@ export default function ContactsPage() { let wheelBurst = 0; let wheelIdleFrames = WHEEL_ACCEL_RESET_FRAMES; const listScroller = createScroller({ - max: () => CONTACT_COUNT * ROW_HEIGHT - LIST_HEIGHT, - extent: () => LIST_HEIGHT, + max: () => CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT, + extent: () => CONTACT_LIST_HEIGHT, }); const detail = createMemo(() => contact(detailIndex())); @@ -80,13 +83,10 @@ export default function ContactsPage() { const next = Math.max(0, Math.min(CONTACT_COUNT - 1, selectedIndex() + delta)); if (next === selectedIndex()) return; setSelectedIndex(next); - const offset = listScroller.offset(); - const rowTop = next * ROW_HEIGHT; - const rowBottom = rowTop + ROW_HEIGHT; - if (rowTop < offset) { - listScroller.scrollTo(rowTop, { immediate: true }); - } else if (rowBottom > offset + LIST_HEIGHT) { - listScroller.scrollTo(rowBottom - LIST_HEIGHT, { immediate: true }); + const maxOffset = CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; + const target = contactScrollTarget(next, listScroller.intent(), maxOffset); + if (target !== null) { + listScroller.springTo(target, { overshootPx: CONTACT_SPRING_OVERSHOOT }); } }; @@ -98,12 +98,7 @@ export default function ContactsPage() { wheelBurst += 1; } wheelIdleFrames = 0; - const gear = Math.min( - WHEEL_ACCEL_MAX_GEAR, - Math.floor(wheelBurst / WHEEL_ACCEL_EVENTS_PER_GEAR), - ); - const multiplier = 1 << gear; - return direction * multiplier; + return direction * wheelMultiplier(wheelBurst); }; onFrame((buttons) => { @@ -168,9 +163,9 @@ export default function ContactsPage() { false} diff --git a/tests/kinetics.test.ts b/tests/kinetics.test.ts index 3445613b5..6e1167af5 100644 --- a/tests/kinetics.test.ts +++ b/tests/kinetics.test.ts @@ -190,6 +190,78 @@ describe("chase (im parity)", () => { }); }); +describe("retargetable spring", () => { + test("approaches through a bounded overshoot and returns to the exact final target", () => { + const s = createScroller({ max: () => 1000 }); + s.springTo(400, { overshootPx: 12 }); + expect(s.intent()).toBe(400); + const t = trace(s); + expect(Math.max(...t)).toBeGreaterThan(400); + expect(Math.max(...t)).toBeLessThanOrEqual(412); + expect(t[t.length - 1]).toBe(400); + }); + + test("retargeting preserves velocity and never jumps the current offset", () => { + const s = createScroller({ max: () => 2000 }); + s.springTo(300, { overshootPx: 12 }); + s.step(); + s.step(); + const beforeOffset = s.offset(); + const beforeVelocity = s.velocity(); + s.springTo(900, { overshootPx: 12 }); + expect(s.offset()).toBe(beforeOffset); + expect(s.velocity()).toBe(beforeVelocity); + expect(s.intent()).toBe(900); + expect(trace(s).at(-1)).toBe(900); + }); + + test("a reverse target brakes the preserved velocity before moving back", () => { + const s = createScroller({ max: () => 2000 }); + s.springTo(1200, { overshootPx: 12 }); + for (let i = 0; i < 5; i++) s.step(); + const atReverse = s.offset(); + expect(s.velocity()).toBeGreaterThan(0); + s.springTo(0, { overshootPx: 12 }); + const t = trace(s); + expect(Math.max(...t)).toBeGreaterThan(atReverse); + expect(Math.min(...t)).toBeGreaterThanOrEqual(-12); + expect(t.at(-1)).toBe(0); + }); + + test("clamps final intent while permitting only the requested edge overshoot", () => { + const s = createScroller({ max: () => 100, initial: 50 }); + s.springTo(999, { overshootPx: 12 }); + expect(s.intent()).toBe(100); + const down = trace(s); + expect(Math.max(...down)).toBeLessThanOrEqual(112); + expect(down.at(-1)).toBe(100); + + s.springTo(-999, { overshootPx: 12 }); + expect(s.intent()).toBe(0); + const up = trace(s); + expect(Math.min(...up)).toBeGreaterThanOrEqual(-12); + expect(up.at(-1)).toBe(0); + }); + + test("30 Hz spring trajectory is the 60 Hz trajectory subsampled", () => { + withHz(60); + const s60 = createScroller({ max: () => 2000 }); + s60.springTo(1000, { overshootPx: 12 }); + const t60 = trace(s60); + + withHz(30); + const s30 = createScroller({ max: () => 2000 }); + s30.springTo(1000, { overshootPx: 12 }); + const t30 = trace(s30); + + for (let i = 0; i < t30.length; i++) { + const at60 = 2 * i + 1; + expect(t30[i]).toBe(t60[Math.min(at60, t60.length - 1)]); + } + expect(t30.at(-1)).toBe(t60.at(-1)); + }); +}); + describe("tween + snap", () => { test("scrollTo lands exactly at the target after round(durMs·hz/1000) frames", () => { const s = createScroller({ max: () => 1000 }); diff --git a/tests/rockbox-contact-motion.test.ts b/tests/rockbox-contact-motion.test.ts new file mode 100644 index 000000000..488747bc2 --- /dev/null +++ b/tests/rockbox-contact-motion.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { + CONTACT_DOWN_ANCHOR_Y, + CONTACT_LIST_HEIGHT, + CONTACT_ROW_HEIGHT, + CONTACT_UP_ANCHOR_Y, + contactScrollTarget, + wheelMultiplier, +} from "../hosts/rockbox/demo/contact-motion.ts"; + +const COUNT = 10_000; +const MAX_OFFSET = COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; + +describe("Rockbox contact wheel motion", () => { + test("keeps slow downward selection inside the viewport before scrolling", () => { + for (let index = 1; index <= 4; index++) { + expect(contactScrollTarget(index, 0, MAX_OFFSET)).toBeNull(); + } + const target = contactScrollTarget(5, 0, MAX_OFFSET); + expect(target).toBe(6); + expect(5 * CONTACT_ROW_HEIGHT - target!).toBe(CONTACT_DOWN_ANCHOR_Y); + }); + + test("uses mirrored one-row resting anchors", () => { + const down = contactScrollTarget(20, 0, MAX_OFFSET)!; + expect(20 * CONTACT_ROW_HEIGHT - down).toBe(CONTACT_DOWN_ANCHOR_Y); + + const up = contactScrollTarget(10, 600, MAX_OFFSET)!; + expect(10 * CONTACT_ROW_HEIGHT - up).toBe(CONTACT_UP_ANCHOR_Y); + }); + + test("lets a 1024-row destination leave the screen before the list follows", () => { + expect(wheelMultiplier(30)).toBe(1024); + expect(wheelMultiplier(300)).toBe(1024); + const index = 1024; + expect(index * CONTACT_ROW_HEIGHT).toBeGreaterThan(CONTACT_LIST_HEIGHT); + const target = contactScrollTarget(index, 0, MAX_OFFSET)!; + expect(index * CONTACT_ROW_HEIGHT - target).toBe(CONTACT_DOWN_ANCHOR_Y); + }); + + test("clamps at real data edges instead of creating blank contacts", () => { + expect(contactScrollTarget(0, 500, MAX_OFFSET)).toBe(0); + expect(contactScrollTarget(COUNT - 1, 0, MAX_OFFSET)).toBe(MAX_OFFSET); + }); +}); diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index adffeaa1e..75dacbf16 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -87,11 +87,11 @@ describe("Rockbox iPod classic development profile", () => { expect(contactsPage).toContain("const CONTACT_COUNT = 10_000"); expect(contactsPage).toContain(" Date: Mon, 31 Aug 2026 21:43:46 -0400 Subject: [PATCH 11/19] fix(rockbox): strengthen contact spring --- framework/src/kinetics-core.ts | 30 ++++++++++++++++++++---- hosts/rockbox/demo/contact-motion.ts | 29 +++++++++++++++++++++++ hosts/rockbox/demo/contacts-page.tsx | 35 +++++++++++++++++++++++----- tests/kinetics.test.ts | 12 ++++++++++ tests/rockbox-contact-motion.test.ts | 15 ++++++++++++ tests/rockbox-profile.test.ts | 3 ++- 6 files changed, 112 insertions(+), 12 deletions(-) diff --git a/framework/src/kinetics-core.ts b/framework/src/kinetics-core.ts index 0d6e83301..f611a3397 100644 --- a/framework/src/kinetics-core.ts +++ b/framework/src/kinetics-core.ts @@ -75,9 +75,13 @@ export interface Scroller { /** Chase an absolute target (focus-follow, stick-to-bottom). */ chaseTo(to: number): void; /** Spring toward an absolute target. Repeated calls preserve velocity. - * `overshootPx` adds a bounded approach point beyond the final target, - * then returns to the clamped target. */ - springTo(to: number, opts?: { overshootPx?: number }): void; + * `overshootPx` adds a bounded approach point beyond the final target; + * stiffness/damping default to the edge-spring constants. */ + springTo(to: number, opts?: { + overshootPx?: number; + stiffness?: number; + damping?: number; + }): void; /** Shift offset AND every in-flight anchor by delta after a prepend, so * backfill never moves what the user is looking at (the im rebase). */ rebase(delta: number): void; @@ -153,6 +157,8 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S let springDirection = 0; // approach direction, -1 / 0 / 1 let springOvershoot = 0; let springReturning = false; + let springK = SPRING_K; + let springC = SPRING_C; let tweenFrom = 0; let tweenTo = 0; let tweenFrames = 1; @@ -205,6 +211,8 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S springDirection = 0; springOvershoot = 0; springReturning = false; + springK = SPRING_K; + springC = SPRING_C; state = "spring"; } @@ -289,18 +297,30 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S state = "chase"; }, - springTo(to: number, o?: { overshootPx?: number }): void { + springTo(to: number, o?: { + overshootPx?: number; + stiffness?: number; + damping?: number; + }): void { const final = clampRange(to); const delta = final - pos; const direction = delta < 0 ? -1 : delta > 0 ? 1 : 0; const requested = o?.overshootPx ?? 0; const approach = Number.isFinite(requested) && requested > 0 ? requested : 0; + const requestedK = o?.stiffness; + const requestedC = o?.damping; if (state !== "spring" && state !== "fling") v = 0; springFinal = final; springDirection = direction; springOvershoot = direction === 0 ? 0 : approach; springReturning = false; + springK = requestedK !== undefined && Number.isFinite(requestedK) && requestedK > 0 + ? requestedK + : SPRING_K; + springC = requestedC !== undefined && Number.isFinite(requestedC) && requestedC > 0 + ? requestedC + : SPRING_C; springBound = final + direction * springOvershoot; if (springBound === pos && v === 0) { state = "idle"; @@ -389,7 +409,7 @@ export function createScrollerWith(cell: ScrollerCell, opts: ScrollerOptions): S } } else { const b = springBound; - const a = SPRING_K * (b - p) - SPRING_C * v; + const a = springK * (b - p) - springC * v; v += a * TICK_DT; p += v * TICK_DT; const dist = b - p; diff --git a/hosts/rockbox/demo/contact-motion.ts b/hosts/rockbox/demo/contact-motion.ts index aed6297a5..8c2f76d9f 100644 --- a/hosts/rockbox/demo/contact-motion.ts +++ b/hosts/rockbox/demo/contact-motion.ts @@ -4,12 +4,41 @@ export const CONTACT_UP_ANCHOR_Y = CONTACT_ROW_HEIGHT; export const CONTACT_DOWN_ANCHOR_Y = CONTACT_LIST_HEIGHT - 2 * CONTACT_ROW_HEIGHT; export const CONTACT_SPRING_OVERSHOOT = 12; +export const CONTACT_SPRING_STIFFNESS = 480; +export const CONTACT_SPRING_DAMPING = 44; +export const CONTACT_MAX_OFFSCREEN_ROWS = 1.5; +export const CONTACT_MAX_OFFSCREEN_PX = + CONTACT_ROW_HEIGHT * CONTACT_MAX_OFFSCREEN_ROWS; export function wheelMultiplier(burst: number): number { const gear = Math.min(10, Math.floor(Math.max(0, burst) / 3)); return 1 << gear; } +/** Keep the painted selection no farther than maxOffscreenPx beyond either + * viewport edge while its accelerated logical destination may remain far + * ahead. The row's nearest edge is used symmetrically. */ +export function boundedVisualContactIndex( + destinationIndex: number, + offset: number, + count: number, + maxOffscreenPx = CONTACT_MAX_OFFSCREEN_PX, +): number { + const first = Math.max( + 0, + Math.ceil( + (offset - maxOffscreenPx - CONTACT_ROW_HEIGHT) / CONTACT_ROW_HEIGHT, + ), + ); + const last = Math.min( + count - 1, + Math.floor( + (offset + CONTACT_LIST_HEIGHT + maxOffscreenPx) / CONTACT_ROW_HEIGHT, + ), + ); + return Math.max(first, Math.min(last, destinationIndex)); +} + /** Final list offset required to bring a selected row back into the resting * band. null means the row can move inside the band without moving the list. */ export function contactScrollTarget( diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index 8f574c4c8..0eba4a08c 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -11,8 +11,12 @@ import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; import { VirtualList } from "@pocketjs/framework/virtual-list"; import { CONTACT_LIST_HEIGHT, + CONTACT_MAX_OFFSCREEN_PX, CONTACT_ROW_HEIGHT, + CONTACT_SPRING_DAMPING, CONTACT_SPRING_OVERSHOOT, + CONTACT_SPRING_STIFFNESS, + boundedVisualContactIndex, contactScrollTarget, wheelMultiplier, } from "./contact-motion.ts"; @@ -58,8 +62,14 @@ function NavigationBar(props: { title: string; back?: boolean }) { ); } +function SelectionFollower(props: { update: () => void }) { + onFrame(() => props.update()); + return null; +} + export default function ContactsPage() { const [selectedIndex, setSelectedIndex] = createSignal(0); + const [destinationIndex, setDestinationIndex] = createSignal(0); const [detailIndex, setDetailIndex] = createSignal(0); const [detailOpen, setDetailOpen] = createSignal(false); let listPanel: NodeMirror | undefined; @@ -80,16 +90,28 @@ export default function ContactsPage() { }; const moveSelection = (delta: number) => { - const next = Math.max(0, Math.min(CONTACT_COUNT - 1, selectedIndex() + delta)); - if (next === selectedIndex()) return; - setSelectedIndex(next); + const next = Math.max(0, Math.min(CONTACT_COUNT - 1, destinationIndex() + delta)); + if (next === destinationIndex()) return; + setDestinationIndex(next); const maxOffset = CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; const target = contactScrollTarget(next, listScroller.intent(), maxOffset); if (target !== null) { - listScroller.springTo(target, { overshootPx: CONTACT_SPRING_OVERSHOOT }); + listScroller.springTo(target, { + overshootPx: CONTACT_SPRING_OVERSHOOT, + stiffness: CONTACT_SPRING_STIFFNESS, + damping: CONTACT_SPRING_DAMPING, + }); } }; + const updateVisualSelection = () => { + setSelectedIndex(boundedVisualContactIndex( + destinationIndex(), + listScroller.offset(), + CONTACT_COUNT, + )); + }; + const acceleratedWheelDelta = (direction: -1 | 1) => { if (wheelDirection !== direction || wheelIdleFrames >= WHEEL_ACCEL_RESET_FRAMES) { wheelDirection = direction; @@ -115,7 +137,7 @@ export default function ContactsPage() { onButtonPress(BTN.CIRCLE, () => { if (detailOpen()) return; resetWheelAcceleration(); - setDetailIndex(selectedIndex()); + setDetailIndex(destinationIndex()); setDetailOpen(true); if (listPanel) animate(listPanel, "translateX", -64, { dur: 110, easing: "out" }); if (detailPanel) animate(detailPanel, "translateX", 0, { dur: 110, easing: "out" }); @@ -165,7 +187,7 @@ export default function ContactsPage() { count={CONTACT_COUNT} rowHeight={CONTACT_ROW_HEIGHT} height={CONTACT_LIST_HEIGHT} - overscan={CONTACT_ROW_HEIGHT * 2} + overscan={CONTACT_MAX_OFFSCREEN_PX + CONTACT_ROW_HEIGHT} controller={listScroller} focusRows={false} inputActive={() => false} @@ -173,6 +195,7 @@ export default function ContactsPage() { style={{ width: 320 }} /> + diff --git a/tests/kinetics.test.ts b/tests/kinetics.test.ts index 6e1167af5..c8ea8d310 100644 --- a/tests/kinetics.test.ts +++ b/tests/kinetics.test.ts @@ -215,6 +215,18 @@ describe("retargetable spring", () => { expect(trace(s).at(-1)).toBe(900); }); + test("accepts stronger per-follow spring constants without changing defaults", () => { + const normal = createScroller({ max: () => 2000 }); + const strong = createScroller({ max: () => 2000 }); + normal.springTo(1000); + strong.springTo(1000, { stiffness: 480, damping: 44 }); + normal.step(); + strong.step(); + expect(strong.offset()).toBeGreaterThan(normal.offset()); + expect(trace(normal).at(-1)).toBe(1000); + expect(trace(strong).at(-1)).toBe(1000); + }); + test("a reverse target brakes the preserved velocity before moving back", () => { const s = createScroller({ max: () => 2000 }); s.springTo(1200, { overshootPx: 12 }); diff --git a/tests/rockbox-contact-motion.test.ts b/tests/rockbox-contact-motion.test.ts index 488747bc2..31d004e60 100644 --- a/tests/rockbox-contact-motion.test.ts +++ b/tests/rockbox-contact-motion.test.ts @@ -2,8 +2,10 @@ import { describe, expect, test } from "bun:test"; import { CONTACT_DOWN_ANCHOR_Y, CONTACT_LIST_HEIGHT, + CONTACT_MAX_OFFSCREEN_PX, CONTACT_ROW_HEIGHT, CONTACT_UP_ANCHOR_Y, + boundedVisualContactIndex, contactScrollTarget, wheelMultiplier, } from "../hosts/rockbox/demo/contact-motion.ts"; @@ -38,6 +40,19 @@ describe("Rockbox contact wheel motion", () => { expect(index * CONTACT_ROW_HEIGHT - target).toBe(CONTACT_DOWN_ANCHOR_Y); }); + test("keeps the painted selection within 1.5 rows beyond either edge", () => { + const down = boundedVisualContactIndex(1024, 0, COUNT); + const downNearEdge = down * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; + expect(downNearEdge).toBeGreaterThanOrEqual(0); + expect(downNearEdge).toBeLessThanOrEqual(CONTACT_MAX_OFFSCREEN_PX); + + const offset = 3000; + const up = boundedVisualContactIndex(0, offset, COUNT); + const upNearEdge = offset - (up + 1) * CONTACT_ROW_HEIGHT; + expect(upNearEdge).toBeGreaterThanOrEqual(0); + expect(upNearEdge).toBeLessThanOrEqual(CONTACT_MAX_OFFSCREEN_PX); + }); + test("clamps at real data edges instead of creating blank contacts", () => { expect(contactScrollTarget(0, 500, MAX_OFFSET)).toBe(0); expect(contactScrollTarget(COUNT - 1, 0, MAX_OFFSET)).toBe(MAX_OFFSET); diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 75dacbf16..f66a8ba24 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -89,10 +89,11 @@ describe("Rockbox iPod classic development profile", () => { expect(contactsPage).toContain("focusRows={false}"); expect(contactsPage).toContain("contactScrollTarget("); expect(contactsPage).toContain("listScroller.springTo(target"); + expect(contactsPage).toContain("boundedVisualContactIndex("); expect(contactsPage).toContain("acceleratedWheelDelta(-1)"); expect(contactsPage).toContain("acceleratedWheelDelta(1)"); expect(contactsPage).toContain("wheelMultiplier(wheelBurst)"); - expect(contactsPage).toContain("setDetailIndex(selectedIndex())"); + expect(contactsPage).toContain("setDetailIndex(destinationIndex())"); expect(contactsPage).toContain("style={{ width: 320 }}"); expect(contactsPage).toContain('top-[36] w-[320] h-[204]'); expect(contactsPage).toContain('{ dur: 110, easing: "out" }'); From ad033c7f7fff206ea259fca76671d230da3071c4 Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 21:58:14 -0400 Subject: [PATCH 12/19] perf(rockbox): render native RGB565 damage --- engine/symbian/src/lib.rs | 78 +++++++++++++++++-- hosts/iphone2g/pocket_core.h | 1 + hosts/iphone2g/pocket_runtime.c | 5 ++ hosts/iphone2g/pocket_runtime.h | 4 +- hosts/rockbox/main.c | 40 ++++++++-- hosts/rockbox/pocketjs.make | 1 - hosts/symbian/runtime/pocketjs_symbian_core.h | 1 + tests/rockbox-profile.test.ts | 9 +++ 8 files changed, 125 insertions(+), 14 deletions(-) diff --git a/engine/symbian/src/lib.rs b/engine/symbian/src/lib.rs index a2e9f4afe..9c183dcab 100644 --- a/engine/symbian/src/lib.rs +++ b/engine/symbian/src/lib.rs @@ -9,7 +9,8 @@ //! QGLWidget owns the graphics context and calls the GLES2 entry points only //! while it is current. The software capture entry points return tightly //! packed, top-left-origin ARGB32 pixels; those pointers remain valid until -//! the next capture, viewport change, init, or shutdown call. +//! the next capture, viewport change, init, or shutdown call. Framebuffer-only +//! hosts may instead render directly into host-owned RGB565 storage. #![cfg_attr(any(target_os = "none", feature = "bare-platform"), no_std)] #![cfg_attr( @@ -159,6 +160,7 @@ static mut DAMAGE_BOUNDS: [i32; 4] = [0, 0, 0, 0]; static mut FRAMEBUFFER_WIDTH: u32 = 0; static mut FRAMEBUFFER_HEIGHT: u32 = 0; static mut FRAMEBUFFER_STRIDE: u32 = 0; +static mut FRAMEBUFFER_LENGTH: usize = 0; /// Stock cores have no application-specific native surface. A custom static /// library depends on this crate with default features disabled and exports @@ -202,6 +204,7 @@ fn clear_framebuffer() { FRAMEBUFFER_WIDTH = 0; FRAMEBUFFER_HEIGHT = 0; FRAMEBUFFER_STRIDE = 0; + FRAMEBUFFER_LENGTH = 0; } } @@ -667,11 +670,12 @@ fn framebuffer_geometry(instance: &Ui, scale: u32) -> Option<(usize, usize, usiz Some((width, height, byte_len)) } -fn remember_framebuffer_geometry(width: usize, height: usize) { +fn remember_framebuffer_geometry(width: usize, height: usize, bytes_per_pixel: usize) { unsafe { FRAMEBUFFER_WIDTH = width as u32; FRAMEBUFFER_HEIGHT = height as u32; - FRAMEBUFFER_STRIDE = (width * 4) as u32; + FRAMEBUFFER_STRIDE = (width * bytes_per_pixel) as u32; + FRAMEBUFFER_LENGTH = width * height * bytes_per_pixel; } } @@ -737,11 +741,66 @@ fn render_at_scale(scale: u32, incremental: bool) -> *const u8 { DAMAGE_BOUNDS = [0, 0, width as i32, height as i32]; } - remember_framebuffer_geometry(width, height); + remember_framebuffer_geometry(width, height, 4); FRAMEBUFFER.as_ptr() } } +/// Render incrementally into a caller-owned native RGB565 framebuffer. +/// +/// Framebuffer-only hosts use this path to avoid allocating an intermediate +/// ARGB surface and converting every pixel after each render. The buffer is +/// tightly packed at the logical viewport size and remains owned by the host. +#[no_mangle] +pub extern "C" fn ui_render_rgb565_incremental(framebuffer: *mut u16, pixel_count: usize) -> i32 { + if framebuffer.is_null() { + return 0; + } + let instance = ui(); + let Some((width, height, byte_len)) = framebuffer_geometry(instance, 1) else { + return 0; + }; + let expected_pixels = byte_len / 4; + if pixel_count != expected_pixels { + return 0; + } + let draw_list: *const pocketjs_core::DrawList = instance.draw(); + let instance_ref: &Ui = unsafe { &*(instance as *const Ui) }; + let output = unsafe { core::slice::from_raw_parts_mut(framebuffer, pixel_count) }; + + unsafe { + DAMAGE_ATTEMPTS = DAMAGE_ATTEMPTS.wrapping_add(1); + match raster::render_scaled_rgb565_incremental( + instance_ref, + &(*draw_list).words, + output, + 1, + &mut DAMAGE_TRACKER, + DamagePolicy::default(), + ) { + Ok(plan) => { + let bounds = plan.bounds(); + DAMAGE_REGIONS = plan.region_count() as u32; + DAMAGE_PIXELS = plan.area(); + DAMAGE_BOUNDS = [bounds.x0, bounds.y0, bounds.x1, bounds.y1]; + if plan.is_full_redraw() { + DAMAGE_FULL_REDRAWS = DAMAGE_FULL_REDRAWS.wrapping_add(1); + } + } + Err(_) => { + DAMAGE_FAILURES = DAMAGE_FAILURES.wrapping_add(1); + DAMAGE_REGIONS = 0; + DAMAGE_PIXELS = pixel_count as u64; + DAMAGE_BOUNDS = [0, 0, width as i32, height as i32]; + raster::render_scaled_rgb565(instance_ref, &(*draw_list).words, output, 1); + DAMAGE_TRACKER.invalidate(); + } + } + remember_framebuffer_geometry(width, height, 2); + } + 1 +} + /// Incremental-raster statistics, so a host can tell a working damage plan /// from a silent per-frame fallback. Counts are cumulative; the region, /// pixel and bounds values describe the most recent frame. @@ -829,7 +888,7 @@ pub extern "C" fn ui_framebuffer_stride() -> u32 { #[no_mangle] pub extern "C" fn ui_framebuffer_len() -> usize { - unsafe { FRAMEBUFFER.len() } + unsafe { FRAMEBUFFER_LENGTH } } #[cfg(test)] @@ -867,6 +926,15 @@ mod tests { assert_eq!(ui_framebuffer_len(), 8); let pixels = unsafe { core::slice::from_raw_parts(framebuffer, 8) }; assert_eq!(pixels, &[0x11, 0x22, 0x33, 0xff, 0x11, 0x22, 0x33, 0xff]); + + let mut rgb565 = [0u16; 2]; + assert_eq!( + ui_render_rgb565_incremental(rgb565.as_mut_ptr(), rgb565.len()), + 1 + ); + assert_eq!(rgb565, [raster::pack_rgb565(0x33, 0x22, 0x11); 2]); + assert_eq!(ui_framebuffer_stride(), 4); + assert_eq!(ui_framebuffer_len(), 4); ui_shutdown(); } } diff --git a/hosts/iphone2g/pocket_core.h b/hosts/iphone2g/pocket_core.h index ce76cff5b..d70e2fe52 100644 --- a/hosts/iphone2g/pocket_core.h +++ b/hosts/iphone2g/pocket_core.h @@ -58,6 +58,7 @@ int32_t ui_debug_rect_wh(void); void ui_debug_pause(int32_t paused); void ui_debug_step(void); const uint8_t *ui_render_incremental(void); +int32_t ui_render_rgb565_incremental(uint16_t *framebuffer, size_t pixel_count); uint32_t ui_framebuffer_width(void); uint32_t ui_framebuffer_height(void); uint32_t ui_framebuffer_stride(void); diff --git a/hosts/iphone2g/pocket_runtime.c b/hosts/iphone2g/pocket_runtime.c index 53ef4af19..69936a4bf 100644 --- a/hosts/iphone2g/pocket_runtime.c +++ b/hosts/iphone2g/pocket_runtime.c @@ -824,6 +824,11 @@ const uint8_t *pocket_runtime_render(void) { return ui_render_incremental(); } +int pocket_runtime_render_rgb565(uint16_t *framebuffer, size_t pixel_count) { + if (runtime == 0 || context == 0 || runtime_failed) return 0; + return ui_render_rgb565_incremental(framebuffer, pixel_count) != 0; +} + unsigned long pocket_runtime_damage_attempts(void) { if (runtime == 0 || context == 0 || runtime_failed) return 0; return (unsigned long)ui_damage_attempts(); diff --git a/hosts/iphone2g/pocket_runtime.h b/hosts/iphone2g/pocket_runtime.h index 39ed09b77..2c00a8a1d 100644 --- a/hosts/iphone2g/pocket_runtime.h +++ b/hosts/iphone2g/pocket_runtime.h @@ -78,11 +78,13 @@ int pocket_runtime_hit_test_bounds(float x, float y); const char *pocket_runtime_action_name(void); int pocket_runtime_action_value(void); unsigned long pocket_runtime_action_sequence(void); -const uint8_t *pocket_runtime_render(void); /* * Rendered pixels are opaque top-left BGRA bytes (ARGB32 words). The pointer * remains valid only until the next render, viewport change, or shutdown. */ +const uint8_t *pocket_runtime_render(void); +/* Render directly into a persistent, tightly packed host-owned RGB565 buffer. */ +int pocket_runtime_render_rgb565(uint16_t *framebuffer, size_t pixel_count); /* * Damage statistics for the software raster path, and the most recent plan's diff --git a/hosts/rockbox/main.c b/hosts/rockbox/main.c index 023e3c809..e253d1ed2 100644 --- a/hosts/rockbox/main.c +++ b/hosts/rockbox/main.c @@ -1,7 +1,6 @@ #include "plugin.h" #include -#include "framebuffer.h" #include "input.h" #include "pocket_runtime.h" #include "pocket_spec.h" @@ -77,7 +76,9 @@ static void pocketjs_runtime_thread(void) { while (true) { const long event = rb->button_get_w_tmo(1); uint32_t buttons; - const uint8_t *pixels; + int damage[4]; + int damage_width; + int damage_height; if (event != BUTTON_NONE) { if (rockbox_input_exit_requested((int)event, &input_codes)) break; @@ -101,16 +102,41 @@ static void pocketjs_runtime_thread(void) { break; } - pixels = pocket_runtime_render(); - if (pixels == 0 || pocket_runtime_width() != LCD_WIDTH || + if (!pocket_runtime_render_rgb565( + (uint16_t *)display, + LCD_WIDTH * LCD_HEIGHT + ) || pocket_runtime_width() != LCD_WIDTH || pocket_runtime_height() != LCD_HEIGHT) { rb->splash(HZ * 3, "PocketJS: invalid framebuffer"); status = PLUGIN_ERROR; break; } - rockbox_bgra_to_rgb565((uint16_t *)display, pixels, LCD_WIDTH * LCD_HEIGHT); - rb->lcd_bitmap(display, 0, 0, LCD_WIDTH, LCD_HEIGHT); - rb->lcd_update(); + if (!pocket_runtime_damage_bounds(damage)) continue; + + if (damage[0] < 0) damage[0] = 0; + if (damage[1] < 0) damage[1] = 0; + if (damage[2] > LCD_WIDTH) damage[2] = LCD_WIDTH; + if (damage[3] > LCD_HEIGHT) damage[3] = LCD_HEIGHT; + damage_width = damage[2] - damage[0]; + damage_height = damage[3] - damage[1]; + if (damage_width <= 0 || damage_height <= 0) continue; + + rb->lcd_bitmap_part( + display, + damage[0], + damage[1], + LCD_WIDTH, + damage[0], + damage[1], + damage_width, + damage_height + ); + rb->lcd_update_rect( + damage[0], + damage[1], + damage_width, + damage_height + ); } cleanup: diff --git a/hosts/rockbox/pocketjs.make b/hosts/rockbox/pocketjs.make index a113146e0..bd92b0e38 100644 --- a/hosts/rockbox/pocketjs.make +++ b/hosts/rockbox/pocketjs.make @@ -4,7 +4,6 @@ POCKETJS_BUILDDIR := $(BUILDDIR)/apps/plugins/pocketjs POCKETJS_SRC := \ $(POCKETJS_SRCDIR)/main.c \ $(POCKETJS_SRCDIR)/input.c \ - $(POCKETJS_SRCDIR)/framebuffer.c \ $(POCKETJS_SRCDIR)/compat.c \ $(POCKETJS_SRCDIR)/runtime_port.c \ $(POCKETJS_SRCDIR)/app_data.c \ diff --git a/hosts/symbian/runtime/pocketjs_symbian_core.h b/hosts/symbian/runtime/pocketjs_symbian_core.h index a59a1216b..fd6dba132 100644 --- a/hosts/symbian/runtime/pocketjs_symbian_core.h +++ b/hosts/symbian/runtime/pocketjs_symbian_core.h @@ -99,6 +99,7 @@ int32_t ui_gl_render_over( int32_t window_height ); const uint8_t *ui_render_incremental(void); +int32_t ui_render_rgb565_incremental(uint16_t *framebuffer, size_t pixel_count); uint32_t ui_framebuffer_width(void); uint32_t ui_framebuffer_height(void); uint32_t ui_framebuffer_stride(void); diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index f66a8ba24..985772c7e 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -62,6 +62,15 @@ describe("Rockbox iPod classic development profile", () => { ); }); + test("renders native RGB565 and presents only the damaged LCD region", () => { + expect(nativeHost).toContain("pocket_runtime_render_rgb565("); + expect(nativeHost).toContain("pocket_runtime_damage_bounds(damage)"); + expect(nativeHost).toContain("rb->lcd_bitmap_part("); + expect(nativeHost).toContain("rb->lcd_update_rect("); + expect(nativeHost).not.toContain("rockbox_bgra_to_rgb565("); + expect(nativeHost).not.toContain("rb->lcd_update();"); + }); + test("ships three hardware-switchable acceptance pages", () => { expect(demoMain).toContain("const PAGE_COUNT = 3"); expect(demoMain).toContain("buttons & BTN.CIRCLE"); From 751751fd9ee60dd2f6e622733aea1d736c3e6315 Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 22:02:15 -0400 Subject: [PATCH 13/19] fix(rockbox): drop wheel inertia on release --- hosts/rockbox/demo/contacts-page.tsx | 23 +++++++++++++++++++++++ tests/rockbox-contact-motion.test.ts | 24 ++++++++++++++++++++++++ tests/rockbox-profile.test.ts | 2 ++ 3 files changed, 49 insertions(+) diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index 0eba4a08c..b7f5f524b 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -112,6 +112,25 @@ export default function ContactsPage() { )); }; + const settleReleasedSelection = () => { + const maxOffset = CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; + const target = contactScrollTarget( + destinationIndex(), + listScroller.offset(), + maxOffset, + ); + // Wheel pulses have no physical release event. Once the burst timeout + // expires, discard all accumulated velocity; only a fresh, non-overshoot + // spring may move the list toward its resting anchor. + listScroller.stop(); + if (target !== null) { + listScroller.springTo(target, { + stiffness: CONTACT_SPRING_STIFFNESS, + damping: CONTACT_SPRING_DAMPING, + }); + } + }; + const acceleratedWheelDelta = (direction: -1 | 1) => { if (wheelDirection !== direction || wheelIdleFrames >= WHEEL_ACCEL_RESET_FRAMES) { wheelDirection = direction; @@ -131,6 +150,10 @@ export default function ContactsPage() { moveSelection(acceleratedWheelDelta(1)); } else { wheelIdleFrames = Math.min(WHEEL_ACCEL_RESET_FRAMES, wheelIdleFrames + 1); + if (wheelDirection !== 0 && wheelIdleFrames === WHEEL_ACCEL_RESET_FRAMES) { + settleReleasedSelection(); + resetWheelAcceleration(); + } } }); diff --git a/tests/rockbox-contact-motion.test.ts b/tests/rockbox-contact-motion.test.ts index 31d004e60..f9a478a0e 100644 --- a/tests/rockbox-contact-motion.test.ts +++ b/tests/rockbox-contact-motion.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { createScroller } from "../framework/src/kinetics.ts"; import { CONTACT_DOWN_ANCHOR_Y, CONTACT_LIST_HEIGHT, @@ -57,4 +58,27 @@ describe("Rockbox contact wheel motion", () => { expect(contactScrollTarget(0, 500, MAX_OFFSET)).toBe(0); expect(contactScrollTarget(COUNT - 1, 0, MAX_OFFSET)).toBe(MAX_OFFSET); }); + + test("release discards wheel velocity and leaves only the anchor spring", () => { + const scroller = createScroller({ max: () => 2000 }); + scroller.springTo(900, { + overshootPx: 12, + stiffness: 480, + damping: 44, + }); + for (let frame = 0; frame < 4; frame++) scroller.step(); + const releasedAt = scroller.offset(); + + scroller.stop(); + scroller.springTo(900, { stiffness: 480, damping: 44 }); + const trace = [releasedAt]; + for (let frame = 0; frame < 240 && scroller.state() !== "idle"; frame++) { + scroller.step(); + trace.push(scroller.offset()); + } + + expect(trace[1]).toBeGreaterThan(releasedAt); + expect(Math.max(...trace)).toBeLessThanOrEqual(900); + expect(trace.at(-1)).toBe(900); + }); }); diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 985772c7e..22c6358c1 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -102,6 +102,8 @@ describe("Rockbox iPod classic development profile", () => { expect(contactsPage).toContain("acceleratedWheelDelta(-1)"); expect(contactsPage).toContain("acceleratedWheelDelta(1)"); expect(contactsPage).toContain("wheelMultiplier(wheelBurst)"); + expect(contactsPage).toContain("settleReleasedSelection()"); + expect(contactsPage).toContain("listScroller.stop()"); expect(contactsPage).toContain("setDetailIndex(destinationIndex())"); expect(contactsPage).toContain("style={{ width: 320 }}"); expect(contactsPage).toContain('top-[36] w-[320] h-[204]'); From 3c93cfcc8c14b9a08d58db0be527d4b525bd0fc8 Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 22:05:47 -0400 Subject: [PATCH 14/19] fix(rockbox): tighten contact snap bounds --- hosts/rockbox/demo/contact-motion.ts | 9 ++++++--- tests/rockbox-contact-motion.test.ts | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/hosts/rockbox/demo/contact-motion.ts b/hosts/rockbox/demo/contact-motion.ts index 8c2f76d9f..30ec803c7 100644 --- a/hosts/rockbox/demo/contact-motion.ts +++ b/hosts/rockbox/demo/contact-motion.ts @@ -1,12 +1,15 @@ export const CONTACT_ROW_HEIGHT = 30; export const CONTACT_LIST_HEIGHT = 204; -export const CONTACT_UP_ANCHOR_Y = CONTACT_ROW_HEIGHT; +export const CONTACT_CENTER_ANCHOR_Y = + (CONTACT_LIST_HEIGHT - CONTACT_ROW_HEIGHT) / 2; +export const CONTACT_UP_ANCHOR_Y = + CONTACT_CENTER_ANCHOR_Y - 2 * CONTACT_ROW_HEIGHT; export const CONTACT_DOWN_ANCHOR_Y = - CONTACT_LIST_HEIGHT - 2 * CONTACT_ROW_HEIGHT; + CONTACT_CENTER_ANCHOR_Y + 2 * CONTACT_ROW_HEIGHT; export const CONTACT_SPRING_OVERSHOOT = 12; export const CONTACT_SPRING_STIFFNESS = 480; export const CONTACT_SPRING_DAMPING = 44; -export const CONTACT_MAX_OFFSCREEN_ROWS = 1.5; +export const CONTACT_MAX_OFFSCREEN_ROWS = 0.5; export const CONTACT_MAX_OFFSCREEN_PX = CONTACT_ROW_HEIGHT * CONTACT_MAX_OFFSCREEN_ROWS; diff --git a/tests/rockbox-contact-motion.test.ts b/tests/rockbox-contact-motion.test.ts index f9a478a0e..e3f90a096 100644 --- a/tests/rockbox-contact-motion.test.ts +++ b/tests/rockbox-contact-motion.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; import { createScroller } from "../framework/src/kinetics.ts"; import { + CONTACT_CENTER_ANCHOR_Y, CONTACT_DOWN_ANCHOR_Y, CONTACT_LIST_HEIGHT, CONTACT_MAX_OFFSCREEN_PX, + CONTACT_MAX_OFFSCREEN_ROWS, CONTACT_ROW_HEIGHT, CONTACT_UP_ANCHOR_Y, boundedVisualContactIndex, @@ -20,11 +22,17 @@ describe("Rockbox contact wheel motion", () => { expect(contactScrollTarget(index, 0, MAX_OFFSET)).toBeNull(); } const target = contactScrollTarget(5, 0, MAX_OFFSET); - expect(target).toBe(6); + expect(target).toBe(3); expect(5 * CONTACT_ROW_HEIGHT - target!).toBe(CONTACT_DOWN_ANCHOR_Y); }); - test("uses mirrored one-row resting anchors", () => { + test("uses center-relative +2/-2 row resting anchors", () => { + expect(CONTACT_UP_ANCHOR_Y).toBe( + CONTACT_CENTER_ANCHOR_Y - 2 * CONTACT_ROW_HEIGHT, + ); + expect(CONTACT_DOWN_ANCHOR_Y).toBe( + CONTACT_CENTER_ANCHOR_Y + 2 * CONTACT_ROW_HEIGHT, + ); const down = contactScrollTarget(20, 0, MAX_OFFSET)!; expect(20 * CONTACT_ROW_HEIGHT - down).toBe(CONTACT_DOWN_ANCHOR_Y); @@ -41,7 +49,9 @@ describe("Rockbox contact wheel motion", () => { expect(index * CONTACT_ROW_HEIGHT - target).toBe(CONTACT_DOWN_ANCHOR_Y); }); - test("keeps the painted selection within 1.5 rows beyond either edge", () => { + test("keeps the painted selection within 0.5 rows beyond either edge", () => { + expect(CONTACT_MAX_OFFSCREEN_ROWS).toBe(0.5); + expect(CONTACT_MAX_OFFSCREEN_PX).toBe(CONTACT_ROW_HEIGHT / 2); const down = boundedVisualContactIndex(1024, 0, COUNT); const downNearEdge = down * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; expect(downNearEdge).toBeGreaterThanOrEqual(0); From eaada01929b0ffd923cf4730c44bae79d0a2590b Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 22:12:29 -0400 Subject: [PATCH 15/19] fix(rockbox): detach contact selection bar --- hosts/rockbox/demo/contact-motion.ts | 28 ++++++++------------ hosts/rockbox/demo/contacts-page.tsx | 38 +++++++++++++++------------- tests/rockbox-contact-motion.test.ts | 20 +++++++-------- tests/rockbox-profile.test.ts | 3 ++- 4 files changed, 42 insertions(+), 47 deletions(-) diff --git a/hosts/rockbox/demo/contact-motion.ts b/hosts/rockbox/demo/contact-motion.ts index 30ec803c7..1a02cf180 100644 --- a/hosts/rockbox/demo/contact-motion.ts +++ b/hosts/rockbox/demo/contact-motion.ts @@ -18,28 +18,22 @@ export function wheelMultiplier(burst: number): number { return 1 << gear; } -/** Keep the painted selection no farther than maxOffscreenPx beyond either - * viewport edge while its accelerated logical destination may remain far - * ahead. The row's nearest edge is used symmetrically. */ -export function boundedVisualContactIndex( - destinationIndex: number, +/** Position an independent selection bar over its logical row while possible, + * then clamp the bar itself so no more than maxOffscreenPx can be clipped at + * either viewport edge. The real row remains free to travel far off-screen. */ +export function contactSelectionY( + selectedIndex: number, offset: number, - count: number, maxOffscreenPx = CONTACT_MAX_OFFSCREEN_PX, ): number { - const first = Math.max( - 0, - Math.ceil( - (offset - maxOffscreenPx - CONTACT_ROW_HEIGHT) / CONTACT_ROW_HEIGHT, - ), - ); - const last = Math.min( - count - 1, - Math.floor( - (offset + CONTACT_LIST_HEIGHT + maxOffscreenPx) / CONTACT_ROW_HEIGHT, + const rowY = selectedIndex * CONTACT_ROW_HEIGHT - offset; + return Math.max( + -maxOffscreenPx, + Math.min( + CONTACT_LIST_HEIGHT - CONTACT_ROW_HEIGHT + maxOffscreenPx, + rowY, ), ); - return Math.max(first, Math.min(last, destinationIndex)); } /** Final list offset required to bring a selected row back into the resting diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index b7f5f524b..32228932c 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -16,7 +16,7 @@ import { CONTACT_SPRING_DAMPING, CONTACT_SPRING_OVERSHOOT, CONTACT_SPRING_STIFFNESS, - boundedVisualContactIndex, + contactSelectionY, contactScrollTarget, wheelMultiplier, } from "./contact-motion.ts"; @@ -68,8 +68,8 @@ function SelectionFollower(props: { update: () => void }) { } export default function ContactsPage() { - const [selectedIndex, setSelectedIndex] = createSignal(0); const [destinationIndex, setDestinationIndex] = createSignal(0); + const [selectionY, setSelectionY] = createSignal(0); const [detailIndex, setDetailIndex] = createSignal(0); const [detailOpen, setDetailOpen] = createSignal(false); let listPanel: NodeMirror | undefined; @@ -81,6 +81,7 @@ export default function ContactsPage() { max: () => CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT, extent: () => CONTACT_LIST_HEIGHT, }); + const selected = createMemo(() => contact(destinationIndex())); const detail = createMemo(() => contact(detailIndex())); const resetWheelAcceleration = () => { @@ -93,6 +94,7 @@ export default function ContactsPage() { const next = Math.max(0, Math.min(CONTACT_COUNT - 1, destinationIndex() + delta)); if (next === destinationIndex()) return; setDestinationIndex(next); + setSelectionY(contactSelectionY(next, listScroller.offset())); const maxOffset = CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; const target = contactScrollTarget(next, listScroller.intent(), maxOffset); if (target !== null) { @@ -105,10 +107,9 @@ export default function ContactsPage() { }; const updateVisualSelection = () => { - setSelectedIndex(boundedVisualContactIndex( + setSelectionY(contactSelectionY( destinationIndex(), listScroller.offset(), - CONTACT_COUNT, )); }; @@ -175,26 +176,17 @@ export default function ContactsPage() { const row = (index: number) => { const item = contact(index); - const active = () => selectedIndex() === index; return ( - - {item.given} - + + {item.given} + {item.surname} - + {item.ordinal} - + ); }; @@ -217,6 +209,16 @@ export default function ContactsPage() { renderRow={row} style={{ width: 320 }} /> + + {selected().given} + {selected().surname} + + {selected().ordinal} + + diff --git a/tests/rockbox-contact-motion.test.ts b/tests/rockbox-contact-motion.test.ts index e3f90a096..6d4c3a46b 100644 --- a/tests/rockbox-contact-motion.test.ts +++ b/tests/rockbox-contact-motion.test.ts @@ -8,7 +8,7 @@ import { CONTACT_MAX_OFFSCREEN_ROWS, CONTACT_ROW_HEIGHT, CONTACT_UP_ANCHOR_Y, - boundedVisualContactIndex, + contactSelectionY, contactScrollTarget, wheelMultiplier, } from "../hosts/rockbox/demo/contact-motion.ts"; @@ -49,19 +49,17 @@ describe("Rockbox contact wheel motion", () => { expect(index * CONTACT_ROW_HEIGHT - target).toBe(CONTACT_DOWN_ANCHOR_Y); }); - test("keeps the painted selection within 0.5 rows beyond either edge", () => { + test("keeps the independent selection bar within 0.5 rows of either edge", () => { expect(CONTACT_MAX_OFFSCREEN_ROWS).toBe(0.5); expect(CONTACT_MAX_OFFSCREEN_PX).toBe(CONTACT_ROW_HEIGHT / 2); - const down = boundedVisualContactIndex(1024, 0, COUNT); - const downNearEdge = down * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; - expect(downNearEdge).toBeGreaterThanOrEqual(0); - expect(downNearEdge).toBeLessThanOrEqual(CONTACT_MAX_OFFSCREEN_PX); + const down = contactSelectionY(1024, 0); + expect(down + CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT) + .toBe(CONTACT_MAX_OFFSCREEN_PX); - const offset = 3000; - const up = boundedVisualContactIndex(0, offset, COUNT); - const upNearEdge = offset - (up + 1) * CONTACT_ROW_HEIGHT; - expect(upNearEdge).toBeGreaterThanOrEqual(0); - expect(upNearEdge).toBeLessThanOrEqual(CONTACT_MAX_OFFSCREEN_PX); + const up = contactSelectionY(0, 3000); + expect(-up).toBe(CONTACT_MAX_OFFSCREEN_PX); + + expect(contactSelectionY(12, 300)).toBe(60); }); test("clamps at real data edges instead of creating blank contacts", () => { diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 22c6358c1..38b9f2f86 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -98,7 +98,8 @@ describe("Rockbox iPod classic development profile", () => { expect(contactsPage).toContain("focusRows={false}"); expect(contactsPage).toContain("contactScrollTarget("); expect(contactsPage).toContain("listScroller.springTo(target"); - expect(contactsPage).toContain("boundedVisualContactIndex("); + expect(contactsPage).toContain("contactSelectionY("); + expect(contactsPage).toContain("style={{ translateY: selectionY() }}"); expect(contactsPage).toContain("acceleratedWheelDelta(-1)"); expect(contactsPage).toContain("acceleratedWheelDelta(1)"); expect(contactsPage).toContain("wheelMultiplier(wheelBurst)"); From dea7e32f9f4d5c3b9fd2fbf9986ccfac4cefb92c Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 22:18:35 -0400 Subject: [PATCH 16/19] perf(rockbox): recycle contact rows --- hosts/rockbox/demo/contacts-page.tsx | 109 +++++++++++++++++---------- tests/rockbox-profile.test.ts | 12 ++- 2 files changed, 79 insertions(+), 42 deletions(-) diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index 32228932c..0364300c6 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -1,4 +1,4 @@ -import { Show, createMemo, createSignal } from "solid-js"; +import { For, Show, createMemo, createSignal, type Accessor } from "solid-js"; import { animate } from "@pocketjs/framework/animation"; import { Text, @@ -6,12 +6,13 @@ import { type NodeMirror, } from "@pocketjs/framework/components"; import { BTN } from "@pocketjs/framework/input"; -import { createScroller } from "@pocketjs/framework/kinetics"; +import { + createScroller, + type Scroller, +} from "@pocketjs/framework/kinetics"; import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle"; -import { VirtualList } from "@pocketjs/framework/virtual-list"; import { CONTACT_LIST_HEIGHT, - CONTACT_MAX_OFFSCREEN_PX, CONTACT_ROW_HEIGHT, CONTACT_SPRING_DAMPING, CONTACT_SPRING_OVERSHOOT, @@ -22,6 +23,11 @@ import { } from "./contact-motion.ts"; const CONTACT_COUNT = 10_000; +const CONTACT_WINDOW_ROWS = Math.ceil(CONTACT_LIST_HEIGHT / CONTACT_ROW_HEIGHT) + 2; +const CONTACT_ROW_SLOTS = Array.from( + { length: CONTACT_WINDOW_ROWS }, + (_, index) => index, +); const WHEEL_ACCEL_RESET_FRAMES = 6; const SURNAMES = [ "Adams", "Bennett", "Carter", "Dawson", "Ellis", "Foster", "Garcia", @@ -62,9 +68,56 @@ function NavigationBar(props: { title: string; back?: boolean }) { ); } -function SelectionFollower(props: { update: () => void }) { - onFrame(() => props.update()); - return null; +function ContactRow(props: { index: Accessor }) { + const item = createMemo(() => contact(props.index())); + return ( + + {item().given} + + {item().surname} + + + {item().ordinal} + + + + ); +} + +function RecycledContactList(props: { + scroller: Scroller; + afterStep: () => void; +}) { + const [firstIndex, setFirstIndex] = createSignal(0); + + onFrame(() => { + props.scroller.step(); + const first = Math.max( + 0, + Math.min( + CONTACT_COUNT - CONTACT_WINDOW_ROWS, + Math.floor(props.scroller.offset() / CONTACT_ROW_HEIGHT) - 1, + ), + ); + setFirstIndex(first); + props.afterStep(); + }); + + return ( + + + + {(slot) => firstIndex() + slot} />} + + + + ); } export default function ContactsPage() { @@ -174,23 +227,6 @@ export default function ContactsPage() { if (detailPanel) animate(detailPanel, "translateX", 320, { dur: 110, easing: "out" }); }, { latched: true }); - const row = (index: number) => { - const item = contact(index); - return ( - - {item.given} - - {item.surname} - - - - {item.ordinal} - - - - ); - }; - return ( - false} - renderRow={row} - style={{ width: 320 }} + - {selected().given} - {selected().surname} - - {selected().ordinal} + {selected().given} + + {selected().surname} + + + {selected().ordinal} + - diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 38b9f2f86..23a720a0e 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -94,8 +94,11 @@ describe("Rockbox iPod classic development profile", () => { expect(inputPage).toContain(button); } expect(contactsPage).toContain("const CONTACT_COUNT = 10_000"); - expect(contactsPage).toContain(""); + expect(contactsPage).toContain("firstIndex() * CONTACT_ROW_HEIGHT"); + expect(contactsPage).not.toContain(" { expect(contactsPage).toContain("settleReleasedSelection()"); expect(contactsPage).toContain("listScroller.stop()"); expect(contactsPage).toContain("setDetailIndex(destinationIndex())"); - expect(contactsPage).toContain("style={{ width: 320 }}"); + expect(contactsPage).toContain('class="relative w-[320] h-[204] overflow-hidden"'); + expect(contactsPage).toContain('w-[62] h-[18] text-sm'); + expect(contactsPage).toContain('w-[174] h-[18] text-sm'); + expect(contactsPage).toContain('w-[50] h-[15] text-xs'); expect(contactsPage).toContain('top-[36] w-[320] h-[204]'); expect(contactsPage).toContain('{ dur: 110, easing: "out" }'); expect(contactsPage).toContain('animate(detailPanel, "translateX", 0'); From c504d7ef9bb7bdd959d1a16c7bfd0d48a1b6587e Mon Sep 17 00:00:00 2001 From: gfhdhytghd <3391146750@qq.com> Date: Mon, 31 Aug 2026 22:23:22 -0400 Subject: [PATCH 17/19] fix(rockbox): refine contact selection motion --- hosts/rockbox/demo/contact-motion.ts | 9 +++++++-- hosts/rockbox/demo/contacts-page.tsx | 22 +++++++--------------- tests/rockbox-contact-motion.test.ts | 12 ++++++------ tests/rockbox-profile.test.ts | 7 +++++++ 4 files changed, 27 insertions(+), 23 deletions(-) diff --git a/hosts/rockbox/demo/contact-motion.ts b/hosts/rockbox/demo/contact-motion.ts index 1a02cf180..cbc23e0fc 100644 --- a/hosts/rockbox/demo/contact-motion.ts +++ b/hosts/rockbox/demo/contact-motion.ts @@ -3,9 +3,9 @@ export const CONTACT_LIST_HEIGHT = 204; export const CONTACT_CENTER_ANCHOR_Y = (CONTACT_LIST_HEIGHT - CONTACT_ROW_HEIGHT) / 2; export const CONTACT_UP_ANCHOR_Y = - CONTACT_CENTER_ANCHOR_Y - 2 * CONTACT_ROW_HEIGHT; + CONTACT_CENTER_ANCHOR_Y - 3 * CONTACT_ROW_HEIGHT; export const CONTACT_DOWN_ANCHOR_Y = - CONTACT_CENTER_ANCHOR_Y + 2 * CONTACT_ROW_HEIGHT; + CONTACT_CENTER_ANCHOR_Y + 3 * CONTACT_ROW_HEIGHT; export const CONTACT_SPRING_OVERSHOOT = 12; export const CONTACT_SPRING_STIFFNESS = 480; export const CONTACT_SPRING_DAMPING = 44; @@ -44,6 +44,11 @@ export function contactScrollTarget( maxOffset: number, ): number | null { const rowTop = selectedIndex * CONTACT_ROW_HEIGHT; + const lastRowTop = maxOffset + CONTACT_LIST_HEIGHT - CONTACT_ROW_HEIGHT; + if (rowTop === 0) return currentIntent === 0 ? null : 0; + if (rowTop >= lastRowTop) { + return currentIntent === maxOffset ? null : maxOffset; + } const screenY = rowTop - currentIntent; let target: number; if (screenY > CONTACT_DOWN_ANCHOR_Y) { diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index 0364300c6..f909026fc 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -134,7 +134,6 @@ export default function ContactsPage() { max: () => CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT, extent: () => CONTACT_LIST_HEIGHT, }); - const selected = createMemo(() => contact(destinationIndex())); const detail = createMemo(() => contact(detailIndex())); const resetWheelAcceleration = () => { @@ -204,8 +203,10 @@ export default function ContactsPage() { moveSelection(acceleratedWheelDelta(1)); } else { wheelIdleFrames = Math.min(WHEEL_ACCEL_RESET_FRAMES, wheelIdleFrames + 1); - if (wheelDirection !== 0 && wheelIdleFrames === WHEEL_ACCEL_RESET_FRAMES) { + if (wheelDirection !== 0 && wheelIdleFrames === 1) { settleReleasedSelection(); + } + if (wheelDirection !== 0 && wheelIdleFrames === WHEEL_ACCEL_RESET_FRAMES) { resetWheelAcceleration(); } } @@ -234,23 +235,14 @@ export default function ContactsPage() { class="absolute left-0 top-0 w-[320] h-[240] bg-white overflow-hidden" > + - - {selected().given} - - {selected().surname} - - - {selected().ordinal} - - - diff --git a/tests/rockbox-contact-motion.test.ts b/tests/rockbox-contact-motion.test.ts index 6d4c3a46b..3271e9d3c 100644 --- a/tests/rockbox-contact-motion.test.ts +++ b/tests/rockbox-contact-motion.test.ts @@ -18,20 +18,20 @@ const MAX_OFFSET = COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; describe("Rockbox contact wheel motion", () => { test("keeps slow downward selection inside the viewport before scrolling", () => { - for (let index = 1; index <= 4; index++) { + for (let index = 1; index <= 5; index++) { expect(contactScrollTarget(index, 0, MAX_OFFSET)).toBeNull(); } - const target = contactScrollTarget(5, 0, MAX_OFFSET); + const target = contactScrollTarget(6, 0, MAX_OFFSET); expect(target).toBe(3); - expect(5 * CONTACT_ROW_HEIGHT - target!).toBe(CONTACT_DOWN_ANCHOR_Y); + expect(6 * CONTACT_ROW_HEIGHT - target!).toBe(CONTACT_DOWN_ANCHOR_Y); }); - test("uses center-relative +2/-2 row resting anchors", () => { + test("uses center-relative +3/-3 row resting anchors", () => { expect(CONTACT_UP_ANCHOR_Y).toBe( - CONTACT_CENTER_ANCHOR_Y - 2 * CONTACT_ROW_HEIGHT, + CONTACT_CENTER_ANCHOR_Y - 3 * CONTACT_ROW_HEIGHT, ); expect(CONTACT_DOWN_ANCHOR_Y).toBe( - CONTACT_CENTER_ANCHOR_Y + 2 * CONTACT_ROW_HEIGHT, + CONTACT_CENTER_ANCHOR_Y + 3 * CONTACT_ROW_HEIGHT, ); const down = contactScrollTarget(20, 0, MAX_OFFSET)!; expect(20 * CONTACT_ROW_HEIGHT - down).toBe(CONTACT_DOWN_ANCHOR_Y); diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 23a720a0e..c453ef21c 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -103,10 +103,17 @@ describe("Rockbox iPod classic development profile", () => { expect(contactsPage).toContain("listScroller.springTo(target"); expect(contactsPage).toContain("contactSelectionY("); expect(contactsPage).toContain("style={{ translateY: selectionY() }}"); + expect(contactsPage.indexOf('bg-[#2378d4]')).toBeLessThan( + contactsPage.indexOf(" Date: Mon, 31 Aug 2026 22:32:17 -0400 Subject: [PATCH 18/19] fix(rockbox): settle contacts inside viewport --- hosts/rockbox/demo/contact-motion.ts | 8 +++---- hosts/rockbox/demo/contacts-page.tsx | 32 ++++++++++++++++++++++------ tests/rockbox-contact-motion.test.ts | 19 +++++++++-------- tests/rockbox-profile.test.ts | 11 ++++++---- 4 files changed, 46 insertions(+), 24 deletions(-) diff --git a/hosts/rockbox/demo/contact-motion.ts b/hosts/rockbox/demo/contact-motion.ts index cbc23e0fc..a69edaa8b 100644 --- a/hosts/rockbox/demo/contact-motion.ts +++ b/hosts/rockbox/demo/contact-motion.ts @@ -2,10 +2,9 @@ export const CONTACT_ROW_HEIGHT = 30; export const CONTACT_LIST_HEIGHT = 204; export const CONTACT_CENTER_ANCHOR_Y = (CONTACT_LIST_HEIGHT - CONTACT_ROW_HEIGHT) / 2; -export const CONTACT_UP_ANCHOR_Y = - CONTACT_CENTER_ANCHOR_Y - 3 * CONTACT_ROW_HEIGHT; +export const CONTACT_UP_ANCHOR_Y = 3 * CONTACT_ROW_HEIGHT; export const CONTACT_DOWN_ANCHOR_Y = - CONTACT_CENTER_ANCHOR_Y + 3 * CONTACT_ROW_HEIGHT; + CONTACT_LIST_HEIGHT - 3 * CONTACT_ROW_HEIGHT; export const CONTACT_SPRING_OVERSHOOT = 12; export const CONTACT_SPRING_STIFFNESS = 480; export const CONTACT_SPRING_DAMPING = 44; @@ -58,5 +57,6 @@ export function contactScrollTarget( } else { return null; } - return Math.max(0, Math.min(maxOffset, target)); + const clamped = Math.max(0, Math.min(maxOffset, target)); + return clamped === currentIntent ? null : clamped; } diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index f909026fc..0c060e46a 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -79,6 +79,13 @@ function ContactRow(props: { index: Accessor }) { {item().ordinal} + + ); +} + +function ContactSeparator() { + return ( + ); @@ -86,6 +93,7 @@ function ContactRow(props: { index: Accessor }) { function RecycledContactList(props: { scroller: Scroller; + selectionY: Accessor; afterStep: () => void; }) { const [firstIndex, setFirstIndex] = createSignal(0); @@ -105,6 +113,19 @@ function RecycledContactList(props: { return ( + + {() => } + + - diff --git a/tests/rockbox-contact-motion.test.ts b/tests/rockbox-contact-motion.test.ts index 3271e9d3c..b676534f2 100644 --- a/tests/rockbox-contact-motion.test.ts +++ b/tests/rockbox-contact-motion.test.ts @@ -18,20 +18,19 @@ const MAX_OFFSET = COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; describe("Rockbox contact wheel motion", () => { test("keeps slow downward selection inside the viewport before scrolling", () => { - for (let index = 1; index <= 5; index++) { + for (let index = 1; index <= 3; index++) { expect(contactScrollTarget(index, 0, MAX_OFFSET)).toBeNull(); } - const target = contactScrollTarget(6, 0, MAX_OFFSET); - expect(target).toBe(3); - expect(6 * CONTACT_ROW_HEIGHT - target!).toBe(CONTACT_DOWN_ANCHOR_Y); + const target = contactScrollTarget(4, 0, MAX_OFFSET); + expect(target).toBe(6); + expect(4 * CONTACT_ROW_HEIGHT - target!).toBe(CONTACT_DOWN_ANCHOR_Y); }); - test("uses center-relative +3/-3 row resting anchors", () => { - expect(CONTACT_UP_ANCHOR_Y).toBe( - CONTACT_CENTER_ANCHOR_Y - 3 * CONTACT_ROW_HEIGHT, - ); + test("uses +3/-3 rows from the viewport edges as resting anchors", () => { + expect(CONTACT_CENTER_ANCHOR_Y).toBe(87); + expect(CONTACT_UP_ANCHOR_Y).toBe(3 * CONTACT_ROW_HEIGHT); expect(CONTACT_DOWN_ANCHOR_Y).toBe( - CONTACT_CENTER_ANCHOR_Y + 3 * CONTACT_ROW_HEIGHT, + CONTACT_LIST_HEIGHT - 3 * CONTACT_ROW_HEIGHT, ); const down = contactScrollTarget(20, 0, MAX_OFFSET)!; expect(20 * CONTACT_ROW_HEIGHT - down).toBe(CONTACT_DOWN_ANCHOR_Y); @@ -78,7 +77,9 @@ describe("Rockbox contact wheel motion", () => { const releasedAt = scroller.offset(); scroller.stop(); + expect(scroller.velocity()).toBe(0); scroller.springTo(900, { stiffness: 480, damping: 44 }); + expect(scroller.velocity()).toBe(0); const trace = [releasedAt]; for (let frame = 0; frame < 240 && scroller.state() !== "idle"; frame++) { scroller.step(); diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index c453ef21c..1c4a10220 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -102,10 +102,13 @@ describe("Rockbox iPod classic development profile", () => { expect(contactsPage).toContain("contactScrollTarget("); expect(contactsPage).toContain("listScroller.springTo(target"); expect(contactsPage).toContain("contactSelectionY("); - expect(contactsPage).toContain("style={{ translateY: selectionY() }}"); - expect(contactsPage.indexOf('bg-[#2378d4]')).toBeLessThan( - contactsPage.indexOf(""); + const selectionLayer = contactsPage.indexOf('bg-[#2378d4]'); + const textLayer = contactsPage.indexOf(" Date: Mon, 31 Aug 2026 22:35:55 -0400 Subject: [PATCH 19/19] fix(rockbox): bound contact sprint selection --- hosts/rockbox/demo/contact-motion.ts | 24 +++++++++++++++ hosts/rockbox/demo/contacts-page.tsx | 44 +++++++++++++++++++++++----- tests/rockbox-contact-motion.test.ts | 13 ++++++++ tests/rockbox-profile.test.ts | 2 ++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/hosts/rockbox/demo/contact-motion.ts b/hosts/rockbox/demo/contact-motion.ts index a69edaa8b..40943a751 100644 --- a/hosts/rockbox/demo/contact-motion.ts +++ b/hosts/rockbox/demo/contact-motion.ts @@ -35,6 +35,30 @@ export function contactSelectionY( ); } +/** Keep the real selected row within the same half-row clip bounds as the + * independent selection bar. A remote wheel target may remain far ahead and + * drive the sprint, but it cannot become the selected contact until the list + * has visually caught up to it. */ +export function contactVisibleIndex( + targetIndex: number, + offset: number, + count: number, + maxOffscreenPx = CONTACT_MAX_OFFSCREEN_PX, +): number { + const first = Math.max( + 0, + Math.ceil((offset - maxOffscreenPx) / CONTACT_ROW_HEIGHT), + ); + const last = Math.min( + count - 1, + Math.floor( + (offset + CONTACT_LIST_HEIGHT - CONTACT_ROW_HEIGHT + maxOffscreenPx) / + CONTACT_ROW_HEIGHT, + ), + ); + return Math.max(first, Math.min(last, targetIndex)); +} + /** Final list offset required to bring a selected row back into the resting * band. null means the row can move inside the band without moving the list. */ export function contactScrollTarget( diff --git a/hosts/rockbox/demo/contacts-page.tsx b/hosts/rockbox/demo/contacts-page.tsx index 0c060e46a..239191eff 100644 --- a/hosts/rockbox/demo/contacts-page.tsx +++ b/hosts/rockbox/demo/contacts-page.tsx @@ -19,6 +19,7 @@ import { CONTACT_SPRING_STIFFNESS, contactSelectionY, contactScrollTarget, + contactVisibleIndex, wheelMultiplier, } from "./contact-motion.ts"; @@ -150,6 +151,7 @@ export default function ContactsPage() { let detailPanel: NodeMirror | undefined; let wheelDirection = 0; let wheelBurst = 0; + let wheelTargetIndex = 0; let wheelIdleFrames = WHEEL_ACCEL_RESET_FRAMES; const listScroller = createScroller({ max: () => CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT, @@ -160,16 +162,30 @@ export default function ContactsPage() { const resetWheelAcceleration = () => { wheelDirection = 0; wheelBurst = 0; + wheelTargetIndex = destinationIndex(); wheelIdleFrames = WHEEL_ACCEL_RESET_FRAMES; }; const moveSelection = (delta: number) => { - const next = Math.max(0, Math.min(CONTACT_COUNT - 1, destinationIndex() + delta)); - if (next === destinationIndex()) return; - setDestinationIndex(next); - setSelectionY(contactSelectionY(next, listScroller.offset())); + const nextTarget = Math.max( + 0, + Math.min(CONTACT_COUNT - 1, wheelTargetIndex + delta), + ); + if (nextTarget === wheelTargetIndex) return; + wheelTargetIndex = nextTarget; + const nextSelected = contactVisibleIndex( + wheelTargetIndex, + listScroller.offset(), + CONTACT_COUNT, + ); + setDestinationIndex(nextSelected); + setSelectionY(contactSelectionY(nextSelected, listScroller.offset())); const maxOffset = CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; - const target = contactScrollTarget(next, listScroller.intent(), maxOffset); + const target = contactScrollTarget( + wheelTargetIndex, + listScroller.intent(), + maxOffset, + ); if (target !== null) { listScroller.springTo(target, { overshootPx: CONTACT_SPRING_OVERSHOOT, @@ -180,16 +196,29 @@ export default function ContactsPage() { }; const updateVisualSelection = () => { + const nextSelected = contactVisibleIndex( + wheelTargetIndex, + listScroller.offset(), + CONTACT_COUNT, + ); + setDestinationIndex(nextSelected); setSelectionY(contactSelectionY( - destinationIndex(), + nextSelected, listScroller.offset(), )); }; const settleReleasedSelection = () => { + const selectedIndex = contactVisibleIndex( + wheelTargetIndex, + listScroller.offset(), + CONTACT_COUNT, + ); + wheelTargetIndex = selectedIndex; + setDestinationIndex(selectedIndex); const maxOffset = CONTACT_COUNT * CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT; const target = contactScrollTarget( - destinationIndex(), + selectedIndex, listScroller.offset(), maxOffset, ); @@ -209,6 +238,7 @@ export default function ContactsPage() { if (wheelDirection !== direction || wheelIdleFrames >= WHEEL_ACCEL_RESET_FRAMES) { wheelDirection = direction; wheelBurst = 0; + wheelTargetIndex = destinationIndex(); } else { wheelBurst += 1; } diff --git a/tests/rockbox-contact-motion.test.ts b/tests/rockbox-contact-motion.test.ts index b676534f2..7b585db2f 100644 --- a/tests/rockbox-contact-motion.test.ts +++ b/tests/rockbox-contact-motion.test.ts @@ -10,6 +10,7 @@ import { CONTACT_UP_ANCHOR_Y, contactSelectionY, contactScrollTarget, + contactVisibleIndex, wheelMultiplier, } from "../hosts/rockbox/demo/contact-motion.ts"; @@ -61,6 +62,18 @@ describe("Rockbox contact wheel motion", () => { expect(contactSelectionY(12, 300)).toBe(60); }); + test("keeps the real selected contact within 0.5 rows while a remote target sprints", () => { + const down = contactVisibleIndex(1024, 0, COUNT); + expect(down).toBe(6); + expect(down * CONTACT_ROW_HEIGHT + CONTACT_ROW_HEIGHT - CONTACT_LIST_HEIGHT) + .toBeLessThanOrEqual(CONTACT_MAX_OFFSCREEN_PX); + + const offset = 30_000; + const up = contactVisibleIndex(0, offset, COUNT); + expect(offset - up * CONTACT_ROW_HEIGHT) + .toBeLessThanOrEqual(CONTACT_MAX_OFFSCREEN_PX); + }); + test("clamps at real data edges instead of creating blank contacts", () => { expect(contactScrollTarget(0, 500, MAX_OFFSET)).toBe(0); expect(contactScrollTarget(COUNT - 1, 0, MAX_OFFSET)).toBe(MAX_OFFSET); diff --git a/tests/rockbox-profile.test.ts b/tests/rockbox-profile.test.ts index 1c4a10220..b15f47186 100644 --- a/tests/rockbox-profile.test.ts +++ b/tests/rockbox-profile.test.ts @@ -102,6 +102,8 @@ describe("Rockbox iPod classic development profile", () => { expect(contactsPage).toContain("contactScrollTarget("); expect(contactsPage).toContain("listScroller.springTo(target"); expect(contactsPage).toContain("contactSelectionY("); + expect(contactsPage).toContain("contactVisibleIndex("); + expect(contactsPage).toContain("wheelTargetIndex"); expect(contactsPage).toContain("style={{ translateY: props.selectionY() }}"); const separatorLayer = contactsPage.indexOf(""); const selectionLayer = contactsPage.indexOf('bg-[#2378d4]');