From 9d199ae80f53bd945ed588ea501698b9ef572530 Mon Sep 17 00:00:00 2001 From: Alberto Date: Thu, 23 Jul 2026 21:14:42 +0200 Subject: [PATCH 01/13] refactor(engine): extract backend-agnostic pocket-ui-surface crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the renderer-independent half of the `ui` surface — UiSurface (pak feeding + HostOps), the pak walker, and the DevTools mailbox — out of pocket-ui-wgpu into a new pocket-ui-surface crate. pocket-ui-wgpu keeps the wgpu DrawList backend and re-exports UiSurface/walk_pak/PakEntry, so desktop consumers (uihost, pocket-widget, handheld, note-widget) stay source-compatible. This lets non-GPU hosts (the PocketBook e-ink host) build on the shared surface without dragging wgpu into their dependency tree. --- engine/Cargo.lock | 13 ++++++++++-- engine/Cargo.toml | 2 ++ engine/crates/pocket-ui-surface/Cargo.toml | 13 ++++++++++++ .../src/dbg.rs | 0 engine/crates/pocket-ui-surface/src/lib.rs | 16 +++++++++++++++ .../src/pak.rs | 0 .../src/surface.rs | 0 engine/crates/pocket-ui-wgpu/Cargo.toml | 3 +-- engine/crates/pocket-ui-wgpu/src/lib.rs | 20 +++++++++---------- 9 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 engine/crates/pocket-ui-surface/Cargo.toml rename engine/crates/{pocket-ui-wgpu => pocket-ui-surface}/src/dbg.rs (100%) create mode 100644 engine/crates/pocket-ui-surface/src/lib.rs rename engine/crates/{pocket-ui-wgpu => pocket-ui-surface}/src/pak.rs (100%) rename engine/crates/{pocket-ui-wgpu => pocket-ui-surface}/src/surface.rs (100%) diff --git a/engine/Cargo.lock b/engine/Cargo.lock index 3b26dba8..4138dfe9 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -1617,13 +1617,22 @@ dependencies = [ ] [[package]] -name = "pocket-ui-wgpu" +name = "pocket-ui-surface" version = "0.1.0" dependencies = [ "anyhow", - "bytemuck", "log", "pocket-mod", + "pocketjs-core", +] + +[[package]] +name = "pocket-ui-wgpu" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytemuck", + "pocket-ui-surface", "pocket3d", "pocketjs-core", "wgpu", diff --git a/engine/Cargo.toml b/engine/Cargo.toml index beb9a88b..4230479d 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -10,6 +10,7 @@ resolver = "2" members = [ "crates/pocket-mod", + "crates/pocket-ui-surface", "crates/pocket-ui-wgpu", "crates/pocket-vrm", "crates/pocket-widget", @@ -38,6 +39,7 @@ repository = "https://github.com/pocket-stack/pocketjs" pocket3d = { path = "pocket3d/crates/pocket3d" } pocket3d-bsp = { path = "pocket3d/crates/pocket3d-bsp" } pocket-mod = { path = "crates/pocket-mod" } +pocket-ui-surface = { path = "crates/pocket-ui-surface" } pocket-ui-wgpu = { path = "crates/pocket-ui-wgpu" } pocket-vrm = { path = "crates/pocket-vrm" } pocket-widget = { path = "crates/pocket-widget" } diff --git a/engine/crates/pocket-ui-surface/Cargo.toml b/engine/crates/pocket-ui-surface/Cargo.toml new file mode 100644 index 00000000..dcc11166 --- /dev/null +++ b/engine/crates/pocket-ui-surface/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "pocket-ui-surface" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "The backend-agnostic PocketJS `ui` surface: pak feeding + HostOps for the guest, independent of any renderer" + +[dependencies] +pocket-mod = { workspace = true } +pocketjs-core = { workspace = true } +anyhow = { workspace = true } +log = { workspace = true } diff --git a/engine/crates/pocket-ui-wgpu/src/dbg.rs b/engine/crates/pocket-ui-surface/src/dbg.rs similarity index 100% rename from engine/crates/pocket-ui-wgpu/src/dbg.rs rename to engine/crates/pocket-ui-surface/src/dbg.rs diff --git a/engine/crates/pocket-ui-surface/src/lib.rs b/engine/crates/pocket-ui-surface/src/lib.rs new file mode 100644 index 00000000..7f61553b --- /dev/null +++ b/engine/crates/pocket-ui-surface/src/lib.rs @@ -0,0 +1,16 @@ +//! pocket-ui-surface — the backend-agnostic half of the PocketJS `ui` surface. +//! +//! Owns a [`pocketjs_core::Ui`] core, feeds it app paks (styles, font atlases, +//! images, sprites — the same walk as the PSP's `hosts/psp/src/pak.rs`), and +//! mounts the `ui.*` HostOps surface into a [`pocket_mod::Guest`]. It has NO +//! renderer dependency: the desktop wgpu host (`pocket-ui-wgpu`) and the +//! PocketBook e-ink host (`hosts/pocketbook`) both build on it, pairing it with +//! their own DrawList backends (docs/RUNTIMES.md — a runtime is +//! ⟨Cores, Surfaces, Guest⟩; this crate is the Surface mechanism). + +mod dbg; +mod pak; +mod surface; + +pub use pak::{PakEntry, find_pak, walk_pak}; +pub use surface::UiSurface; diff --git a/engine/crates/pocket-ui-wgpu/src/pak.rs b/engine/crates/pocket-ui-surface/src/pak.rs similarity index 100% rename from engine/crates/pocket-ui-wgpu/src/pak.rs rename to engine/crates/pocket-ui-surface/src/pak.rs diff --git a/engine/crates/pocket-ui-wgpu/src/surface.rs b/engine/crates/pocket-ui-surface/src/surface.rs similarity index 100% rename from engine/crates/pocket-ui-wgpu/src/surface.rs rename to engine/crates/pocket-ui-surface/src/surface.rs diff --git a/engine/crates/pocket-ui-wgpu/Cargo.toml b/engine/crates/pocket-ui-wgpu/Cargo.toml index 8168fd1a..0923b096 100644 --- a/engine/crates/pocket-ui-wgpu/Cargo.toml +++ b/engine/crates/pocket-ui-wgpu/Cargo.toml @@ -8,9 +8,8 @@ description = "The PocketJS `ui` surface on the native desktop base: pak feeding [dependencies] pocket3d = { workspace = true } -pocket-mod = { workspace = true } +pocket-ui-surface = { workspace = true } pocketjs-core = { workspace = true } wgpu = { workspace = true } bytemuck = { workspace = true } anyhow = { workspace = true } -log = { workspace = true } diff --git a/engine/crates/pocket-ui-wgpu/src/lib.rs b/engine/crates/pocket-ui-wgpu/src/lib.rs index d113b646..57538382 100644 --- a/engine/crates/pocket-ui-wgpu/src/lib.rs +++ b/engine/crates/pocket-ui-wgpu/src/lib.rs @@ -1,10 +1,10 @@ -//! pocket-ui-wgpu — the PocketJS `ui` surface on the native desktop base. +//! pocket-ui-wgpu — the PocketJS `ui` surface rendered through wgpu. //! -//! The desktop edition of the 2D UI runtime's native half (docs/RUNTIMES.md): -//! it owns a [`pocketjs_core::Ui`] core, feeds it app paks (styles, font -//! atlases, images, sprites — same walk as the PSP's `hosts/psp/src/pak.rs`), -//! mounts the `ui.*` HostOps surface into a [`pocket_mod::Guest`], and -//! renders the core's DrawList through wgpu into any render target: +//! The desktop edition of the 2D UI runtime's native half (docs/RUNTIMES.md). +//! The backend-agnostic surface — the [`pocketjs_core::Ui`] core, pak feeding, +//! and the `ui.*` HostOps mounted into a [`pocket_mod::Guest`] — lives in +//! `pocket-ui-surface` and is re-exported here unchanged. This crate adds the +//! wgpu DrawList backend that renders that core into any render target: //! //! - a window at PSP resolution → the existing PocketJS demos run natively //! on macOS (see `examples/uihost`); @@ -14,12 +14,10 @@ //! 2D and 3D share one base: the same `pocket3d::Gpu` device drives both. mod blit; -mod dbg; -mod pak; mod render; -mod surface; pub use blit::Blit; -pub use pak::{PakEntry, walk_pak}; pub use render::UiRenderer; -pub use surface::UiSurface; +// The backend-agnostic surface (UiSurface + pak walk) — re-exported so desktop +// consumers (uihost, OpenStrike) stay source-compatible after the split. +pub use pocket_ui_surface::{PakEntry, UiSurface, walk_pak}; From 8fff1838841ba80246744a832c77b68f4c253bfb Mon Sep 17 00:00:00 2001 From: Alberto Date: Thu, 23 Jul 2026 21:14:51 +0200 Subject: [PATCH 02/13] feat(pocket-mod): add Guest::frame_with_touches for touch targets The framework's frame entry point is frame(buttons, analog?, touches?) (framework/src/host.ts), with touches decoded from a packed u32 array ((id<<18)|(y<<9)|x, framework/src/touch.ts). pocket-mod only exposed frame/frame_with_analog, so touch-capable hosts had no way to deliver contacts. Add the 3-arg path as an additive method; frame and frame_with_analog (and every existing host, tape, and golden) are unchanged. --- engine/crates/pocket-mod/src/lib.rs | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/engine/crates/pocket-mod/src/lib.rs b/engine/crates/pocket-mod/src/lib.rs index 0fc0cf5c..d166cd74 100644 --- a/engine/crates/pocket-mod/src/lib.rs +++ b/engine/crates/pocket-mod/src/lib.rs @@ -108,6 +108,34 @@ impl Guest { Ok(()) } + /// One guest turn with touch contacts. `touches` packs each contact as + /// `(id << 18) | (y << 9) | x` (framework/src/touch.ts): 9 bits per axis + /// (so logical coordinates must be ≤ 511), 8 bits of id, up to 8 contacts. + /// A contact present in the array is down/move this frame; absent = released. + /// Hosts without touch call [`Guest::frame`] / [`Guest::frame_with_analog`] + /// instead; this is the 3-arg `globalThis.frame(buttons, analog, touches)` + /// path for touch targets (Vita, PocketBook). + pub fn frame_with_touches(&self, buttons: u32, analog: u32, touches: &[u32]) -> Result<()> { + self.ctx.with(|ctx| -> Result<()> { + let frame: Option = ctx.globals().get("frame").ok(); + if let Some(frame) = frame { + let arr = rquickjs::Array::new(ctx.clone()) + .map_err(|e| anyhow!("pocket-mod: allocating touch array: {e}"))?; + for (i, t) in touches.iter().enumerate() { + arr.set(i, *t) + .map_err(|e| anyhow!("pocket-mod: setting touch {i}: {e}"))?; + } + frame + .call::<_, ()>((buttons, analog, arr)) + .catch(&ctx) + .map_err(|e| anyhow!("pocket-mod: frame() threw: {e}"))?; + } + Ok(()) + })?; + self.drain_jobs(); + Ok(()) + } + /// Drain the microtask/job queue (promise reactions). Job exceptions are /// logged, not fatal — matching how hosts treat stray rejections. pub fn drain_jobs(&self) { @@ -269,6 +297,30 @@ mod tests { assert_eq!(a, pocketjs_core::spec::ANALOG_CENTER); } + #[test] + fn frame_carries_packed_touches() { + let g = Guest::new().unwrap(); + g.eval( + "boot", + "globalThis.res = ''; \ + globalThis.frame = (b, a, t) => { \ + globalThis.res = b + ':' + (t ? t.length : 0) + ':' + (t && t[0] !== undefined ? t[0] : -1); \ + };", + ) + .unwrap(); + // (id<<18)|(y<<9)|x — contact id 0 at logical (10, 20): + let packed = (20u32 << 9) | 10; + g.frame_with_touches(5, pocketjs_core::spec::ANALOG_CENTER, &[packed]) + .unwrap(); + let res: String = g.with(|ctx| ctx.globals().get("res").unwrap()); + assert_eq!(res, format!("5:1:{packed}")); + // No contacts → empty array, frame still turns. + g.frame_with_touches(0, pocketjs_core::spec::ANALOG_CENTER, &[]) + .unwrap(); + let res: String = g.with(|ctx| ctx.globals().get("res").unwrap()); + assert_eq!(res, "0:0:-1"); + } + #[test] fn exceptions_carry_js_stack() { let g = Guest::new().unwrap(); From f91e66338ebff43a75b090a33b5de3e9b5960071 Mon Sep 17 00:00:00 2001 From: Alberto Date: Thu, 23 Jul 2026 21:15:04 +0200 Subject: [PATCH 03/13] feat(hosts): add PocketBook inkview e-ink host A new standalone host (same lone-bin model as hosts/psp and hosts/vita) that runs the PocketJS UI runtime on PocketBook e-readers via inkview: - reuses pocket-ui-surface::UiSurface for the whole `ui` HostOps surface and pak feeding, and the core's software rasterizer (render_scaled) at logical x density resolution; - framebuffer.rs: RGBA8 -> Gray8 luminance conversion with 16x16-tile damage tracking; - refresh.rs: e-ink update policy ported from inkview-slint (is_updating gate; partial when idle, throttled dynamic during motion, 200 ms quiet cleanup to clear ghosting); - input.rs: inkview keys -> spec BTN bitmask, touchscreen -> the packed touch wire format in logical px; - main.rs: iv_main forwards events into a channel; a second thread owns the Screen and the fixed-cadence tick/render loop (the inkview-slint demo model). The logical viewport stays <=511 px/axis so touch coordinates fit the 9-bit wire format; density is configurable (POCKET_DENSITY). Compiles natively, clippy-clean, with framebuffer/input unit tests. On-device validation (refresh tuning, touch accuracy, viewport choice) still needs hardware. See pocketjs-inkview-implementation.md. --- hosts/pocketbook/Cargo.lock | 595 ++++++++++++++++++++++++++++ hosts/pocketbook/Cargo.toml | 42 ++ hosts/pocketbook/README.md | 63 +++ hosts/pocketbook/src/framebuffer.rs | 161 ++++++++ hosts/pocketbook/src/input.rs | 163 ++++++++ hosts/pocketbook/src/main.rs | 279 +++++++++++++ hosts/pocketbook/src/refresh.rs | 130 ++++++ 7 files changed, 1433 insertions(+) create mode 100644 hosts/pocketbook/Cargo.lock create mode 100644 hosts/pocketbook/Cargo.toml create mode 100644 hosts/pocketbook/README.md create mode 100644 hosts/pocketbook/src/framebuffer.rs create mode 100644 hosts/pocketbook/src/input.rs create mode 100644 hosts/pocketbook/src/main.rs create mode 100644 hosts/pocketbook/src/refresh.rs diff --git a/hosts/pocketbook/Cargo.lock b/hosts/pocketbook/Cargo.lock new file mode 100644 index 00000000..171fd323 --- /dev/null +++ b/hosts/pocketbook/Cargo.lock @@ -0,0 +1,595 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.19", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "inkview" +version = "0.3.0" +dependencies = [ + "libloading", + "num-derive", + "num-traits", + "thiserror 1.0.69", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jiff" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pocket-mod" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "pocketjs-core", + "rquickjs", +] + +[[package]] +name = "pocket-ui-surface" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "pocket-mod", + "pocketjs-core", +] + +[[package]] +name = "pocketbook-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "env_logger", + "inkview", + "log", + "pocket-mod", + "pocket-ui-surface", + "pocketjs-core", +] + +[[package]] +name = "pocketjs-core" +version = "0.1.0" +dependencies = [ + "taffy", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + +[[package]] +name = "rquickjs" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce6b626d30ecbedaaf8097a04982bc3b081f958f5bdf9b8796be4ac00ee48bb" +dependencies = [ + "rquickjs-core", +] + +[[package]] +name = "rquickjs-core" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9334dd11023d5ae6c751f64496510a576208802ed1d022ef94afa7639c2c55e" +dependencies = [ + "hashbrown", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eef73520804cf5aa4876097ac5733058d628c769eba9678e0f4dba5a4ef79703" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taffy" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfde4e2f8595f222ceaae1fb16b4963952e9b33e358869dc4cd6316b0e0790cd" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/hosts/pocketbook/Cargo.toml b/hosts/pocketbook/Cargo.toml new file mode 100644 index 00000000..5be5241f --- /dev/null +++ b/hosts/pocketbook/Cargo.toml @@ -0,0 +1,42 @@ +# pocketbook-host — the PocketJS UI runtime on PocketBook e-readers (inkview). +# +# STANDALONE LONE-BIN CRATE (same model as hosts/psp and hosts/vita): it lives +# outside any workspace with its own Cargo.lock, and path-depends on the engine +# crates. It reuses the backend-agnostic `ui` surface (pocket-ui-surface) and +# the core's software rasterizer, then converts RGBA8 → Gray8 for the e-ink +# panel. See pocketjs-inkview-implementation.md at the repo root. +# +# Cross-compile for PocketBook's ARM Linux (glibc 2.23): +# rustup target add armv7-unknown-linux-gnueabi +# cargo install cargo-zigbuild +# cargo zigbuild --release --target armv7-unknown-linux-gnueabi.2.23 +# libinkview.so is dlopen'd at runtime (inkview::load) — no SDK at build time. + +[package] +name = "pocketbook-host" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "PocketJS UI runtime host for PocketBook e-readers via the inkview SDK" + +[dependencies] +pocketjs-core = { path = "../../engine/core", features = ["std"] } +pocket-mod = { path = "../../engine/crates/pocket-mod" } +pocket-ui-surface = { path = "../../engine/crates/pocket-ui-surface" } + +# inkview is consumed as-is (no modifications). During development this points +# at a local checkout; for an upstream/CI build switch to the git dependency: +# inkview = { git = "https://github.com/simmsb/inkview-rs", package = "inkview" } +# Default feature is sdk-6-10; override for older firmware, e.g. +# --no-default-features --features sdk-6-5 +inkview = { path = "../../../inkview-rs/inkview" } + +anyhow = "1" +log = "0.4" +env_logger = "0.11" + +[profile.release] +opt-level = "s" # size-optimized (e-readers have limited storage) +lto = true +strip = true +panic = "abort" diff --git a/hosts/pocketbook/README.md b/hosts/pocketbook/README.md new file mode 100644 index 00000000..669577d6 --- /dev/null +++ b/hosts/pocketbook/README.md @@ -0,0 +1,63 @@ +# pocketbook-host + +The PocketJS UI runtime on **PocketBook e-readers**, rendered through the +[inkview](https://github.com/simmsb/inkview-rs) SDK. + +It reuses the backend-agnostic `ui` surface (`pocket-ui-surface`) and the +core's software rasterizer unchanged, then: + +- rasterizes the DrawList to RGBA8 at `logical × density` resolution + (`pocketjs_core::raster::render_scaled`); +- luminance-converts RGBA8 → Gray8 with 16×16-tile damage tracking + (`framebuffer.rs`); +- drives the e-ink panel with a partial/dynamic/full update policy ported from + `inkview-slint` (`refresh.rs`); +- maps inkview keys → the spec BTN bitmask and the touchscreen → the framework's + packed touch wire format (`input.rs`); +- runs the inkview event loop on the main thread forwarding into a channel, with + a second thread owning the `Screen` and the fixed-cadence tick/render loop + (`main.rs`, same model as the `inkview-slint` demo). + +See **`pocketjs-inkview-implementation.md`** at the repo root for the full +design, the ground-truth API notes, and the open design questions (notably the +logical-viewport / touch-coordinate trade-off in §9). + +## Status + +Phases 0–3 of the implementation plan: the host compiles natively, is clippy +clean, and its framebuffer/input unit tests pass. On-device validation +(refresh tuning, touch accuracy, the §9 viewport choice) still requires +hardware. + +## Build + +Cross-compile for PocketBook's ARM Linux (glibc 2.23): + +```sh +rustup target add armv7-unknown-linux-gnueabi +cargo install cargo-zigbuild +cargo zigbuild --release --target armv7-unknown-linux-gnueabi.2.23 +``` + +`libinkview.so` is `dlopen`'d at runtime (`inkview::load`) — no SDK is needed at +build time. The `inkview` dependency currently points at a local checkout; switch +to the git dependency in `Cargo.toml` for a standalone/CI build. + +## Deploy + +```sh +D=/mnt/ext1/applications/myapp +cp target/armv7-unknown-linux-gnueabi.2.23/release/pocketbook-host $D/myapp +cp dist/.js $D/app.js # PocketJS build output +cp dist/.pak $D/app.pak +chmod +x $D/myapp +``` + +## Runtime configuration + +| Env var | Default | Meaning | +| ------- | ------- | ------- | +| `POCKET_PAK` | `app.pak` | path to the app pak | +| `POCKET_JS` | `app.js` | path to the JS bundle | +| `POCKET_DENSITY` | `2` | raster density (1–4); raise for high-DPI panels so the logical viewport stays ≤511 px/axis | +| `RUST_LOG` | `info` | log filter | diff --git a/hosts/pocketbook/src/framebuffer.rs b/hosts/pocketbook/src/framebuffer.rs new file mode 100644 index 00000000..766f8629 --- /dev/null +++ b/hosts/pocketbook/src/framebuffer.rs @@ -0,0 +1,161 @@ +//! RGBA8 → Gray8 conversion + tile-based damage tracking for the e-ink panel. +//! +//! The core's software rasterizer (`pocketjs_core::raster::render_scaled`) +//! produces an RGBA8 framebuffer at `logical × density` resolution, cleared to +//! opaque black with every pixel alpha=255. We luminance-convert to Gray8 (the +//! panel's native 8-bit grayscale) and diff against the previous frame in +//! 16×16 tiles to drive partial panel updates. + +use inkview::screen::{BB8, Screen}; +use pocketjs_core::{Ui, raster}; + +/// One changed tile, in render-buffer pixel coordinates. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DirtyRect { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + +/// Dirty-tile granularity. 16×16 matches the slint backend's damage grain and +/// keeps partial-update rects small without an excessive rect count. +const TILE: usize = 16; + +pub struct FramebufferPipeline { + rgba: Vec, + gray: Vec, + prev: Vec, + w: usize, + h: usize, + density: u32, +} + +impl FramebufferPipeline { + /// `w`/`h` are the RENDER-buffer dimensions (logical × density). + pub fn new(w: usize, h: usize, density: u32) -> Self { + let n = w * h; + Self { + rgba: vec![0u8; n * 4], + gray: vec![255u8; n], // idle white (e-ink holds image without refresh) + prev: vec![255u8; n], + w, + h, + density, + } + } + + /// Rasterize the DrawList into the RGBA8 buffer at density scale. + /// `render_scaled` asserts the buffer is exactly `viewport×density` px. + pub fn rasterize(&mut self, ui: &Ui, words: &[u32]) { + raster::render_scaled(ui, words, &mut self.rgba, self.density); + } + + /// Luminance-convert RGBA8→Gray8 and return the changed tiles (buffer coords). + pub fn convert_and_diff(&mut self) -> Vec { + let n = self.w * self.h; + for i in 0..n { + let r = self.rgba[i * 4] as u32; + let g = self.rgba[i * 4 + 1] as u32; + let b = self.rgba[i * 4 + 2] as u32; + // Integer form of inkview's 0.2125R + 0.7154G + 0.0721B. + self.gray[i] = ((54 * r + 183 * g + 19 * b) >> 8) as u8; + } + let dirty = self.diff(); + std::mem::swap(&mut self.gray, &mut self.prev); + dirty + } + + fn diff(&self) -> Vec { + let (w, h) = (self.w, self.h); + let tx = w.div_ceil(TILE); + let ty = h.div_ceil(TILE); + let mut flags = vec![false; tx * ty]; + for y in 0..h { + let row = y * w; + for x in 0..w { + let i = row + x; + if self.gray[i] != self.prev[i] { + flags[(y / TILE) * tx + (x / TILE)] = true; + } + } + } + flags + .iter() + .enumerate() + .filter(|(_, d)| **d) + .map(|(i, _)| { + let px = (i % tx) * TILE; + let py = (i / tx) * TILE; + DirtyRect { + x: px, + y: py, + w: TILE.min(w - px), + h: TILE.min(h - py), + } + }) + .collect() + } + + /// Write changed tiles to the inkview framebuffer at screen offset (ox, oy). + /// Issues no panel update — the caller drives that through `refresh`. + pub fn blit_dirty(&self, screen: &mut Screen, dirty: &[DirtyRect], ox: usize, oy: usize) { + for r in dirty { + for y in r.y..(r.y + r.h) { + for x in r.x..(r.x + r.w) { + screen.draw(x + ox, y + oy, BB8(self.gray[y * self.w + x])); + } + } + } + } + + /// Full-buffer blit at offset (used on Show / before a full_update). + pub fn blit_all(&self, screen: &mut Screen, ox: usize, oy: usize) { + for y in 0..self.h { + for x in 0..self.w { + screen.draw(x + ox, y + oy, BB8(self.gray[y * self.w + x])); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn luminance_matches_inkview_coefficients() { + // Pure green → 0.7154*255 ≈ 182. + let mut fb = FramebufferPipeline::new(1, 1, 1); + fb.rgba.copy_from_slice(&[0, 255, 0, 255]); + fb.prev[0] = 0; // force a diff so we can observe gray[0] + fb.convert_and_diff(); + // After the swap, prev holds the freshly computed gray. + assert_eq!(fb.prev[0], ((54 * 0 + 183 * 255 + 19 * 0) >> 8) as u8); + assert!((fb.prev[0] as i32 - 182).abs() <= 1); + } + + #[test] + fn unchanged_frame_has_no_damage() { + let mut fb = FramebufferPipeline::new(32, 32, 1); + // Make gray == prev (both start white) → no damage. + let dirty = fb.convert_and_diff(); + // raster buffer is black → gray becomes 0, differs from white prev. + assert!(!dirty.is_empty()); + // Second pass with the same black buffer: gray == prev now → no damage. + fb.gray.copy_from_slice(&fb.prev); + let dirty2 = fb.convert_and_diff(); + assert!(dirty2.is_empty()); + } + + #[test] + fn damage_reports_tile_bounds() { + let mut fb = FramebufferPipeline::new(32, 32, 1); + // Flip one pixel in the second tile column (x=16..31). + fb.gray.copy_from_slice(&fb.prev); + fb.gray[20] = 0; // y=0, x=20 → tile (1, 0) + // diff() compares gray vs prev directly. + let dirty = fb.diff(); + assert_eq!(dirty, vec![DirtyRect { x: 16, y: 0, w: 16, h: 16 }]); + } +} diff --git a/hosts/pocketbook/src/input.rs b/hosts/pocketbook/src/input.rs new file mode 100644 index 00000000..272fc8b6 --- /dev/null +++ b/hosts/pocketbook/src/input.rs @@ -0,0 +1,163 @@ +//! inkview events → PocketJS input state. +//! +//! Hardware keys map to the spec BTN bitmask (the same bits `uihost` uses); +//! the touchscreen maps to the framework's packed touch wire format +//! (`(id<<18)|(y<<9)|x`, framework/src/touch.ts) in LOGICAL viewport pixels. + +use inkview::event::{Event, Key}; +use pocketjs_core::spec::ANALOG_CENTER; + +// spec BTN bits (contracts/spec/spec.ts; mirrored by uihost). +const BTN_SELECT: u32 = 0x0001; +const BTN_START: u32 = 0x0008; +const BTN_UP: u32 = 0x0010; +const BTN_RIGHT: u32 = 0x0020; +const BTN_DOWN: u32 = 0x0040; +const BTN_LEFT: u32 = 0x0080; +const BTN_LTRIGGER: u32 = 0x0100; +const BTN_RTRIGGER: u32 = 0x0200; +const BTN_CIRCLE: u32 = 0x2000; +const BTN_CROSS: u32 = 0x4000; + +/// What the render loop should do after handling an event. +pub enum Outcome { + Continue, + Quit, + /// A full redraw was requested (e.g. returning from background). + FullRedraw, +} + +pub struct Input { + buttons: u32, + /// Current contact in LOGICAL px (None = up). PocketBook is single-touch. + touch: Option<(u32, u32)>, + density: i32, + ox: i32, + oy: i32, + logical_w: u32, + logical_h: u32, +} + +impl Input { + pub fn new(density: u32, ox: i32, oy: i32, logical_w: u32, logical_h: u32) -> Self { + Self { + buttons: 0, + touch: None, + density: density as i32, + ox, + oy, + logical_w, + logical_h, + } + } + + pub fn on_event(&mut self, ev: Event) -> Outcome { + match ev { + Event::Exit => Outcome::Quit, + Event::Show => Outcome::FullRedraw, + Event::KeyDown { key } | Event::KeyRepeat { key } => { + self.buttons |= key_bit(key); + Outcome::Continue + } + Event::KeyUp { key } => { + self.buttons &= !key_bit(key); + Outcome::Continue + } + Event::PointerDown { x, y } | Event::PointerMove { x, y } => { + self.touch = Some(self.to_logical(x, y)); + Outcome::Continue + } + Event::PointerUp { .. } => { + self.touch = None; + Outcome::Continue + } + _ => Outcome::Continue, + } + } + + /// Physical screen px → logical viewport px (≤511/axis by construction). + fn to_logical(&self, x: i32, y: i32) -> (u32, u32) { + let lx = ((x - self.ox) / self.density).clamp(0, self.logical_w as i32 - 1) as u32; + let ly = ((y - self.oy) / self.density).clamp(0, self.logical_h as i32 - 1) as u32; + (lx, ly) + } + + /// (buttons, analog, packed touches) for `Guest::frame_with_touches`. + pub fn snapshot(&self) -> (u32, u32, Vec) { + let touches = self + .touch + .map(|(x, y)| vec![pack_touch(0, x, y)]) + .unwrap_or_default(); + (self.buttons, ANALOG_CENTER, touches) + } +} + +/// framework/src/touch.ts `__packTouch`: `(id<<18)|(y<<9)|x`. +fn pack_touch(id: u32, x: u32, y: u32) -> u32 { + ((id & 0xff) << 18) | ((y & 0x1ff) << 9) | (x & 0x1ff) +} + +fn key_bit(key: Key) -> u32 { + match key { + Key::Up => BTN_UP, + Key::Down => BTN_DOWN, + Key::Left | Key::Prev | Key::Prev2 => BTN_LEFT, // page-turn = prev + Key::Right | Key::Next | Key::Next2 => BTN_RIGHT, // page-turn = next + Key::Ok => BTN_CROSS, + Key::Back => BTN_CIRCLE, + Key::Menu => BTN_START, + Key::Home => BTN_SELECT, + Key::Plus => BTN_RTRIGGER, + Key::Minus => BTN_LTRIGGER, + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn touch_packs_id_y_x() { + assert_eq!(pack_touch(0, 10, 20), (20 << 9) | 10); + assert_eq!(pack_touch(3, 1, 2), (3 << 18) | (2 << 9) | 1); + // 9-bit clamp at the wire level: + assert_eq!(pack_touch(0, 511, 511), (511 << 9) | 511); + } + + #[test] + fn keys_set_and_clear_button_bits() { + let mut input = Input::new(2, 0, 0, 480, 320); + assert_eq!(input.on_event_matches_key(Key::Ok), BTN_CROSS); + input.apply_key_down(Key::Ok); + assert_eq!(input.snapshot().0 & BTN_CROSS, BTN_CROSS); + input.apply_key_up(Key::Ok); + assert_eq!(input.snapshot().0 & BTN_CROSS, 0); + } + + #[test] + fn pointer_maps_physical_to_logical() { + // density 2, offset (1, 0), logical 511×379. + let mut input = Input::new(2, 1, 0, 511, 379); + input.on_event(Event::PointerDown { x: 103, y: 41 }); + let (_, _, touches) = input.snapshot(); + assert_eq!(touches.len(), 1); + // logical = (physical - offset) / density = (103-1)/2=51, 41/2=20. + assert_eq!(touches[0], pack_touch(0, 51, 20)); + input.on_event(Event::PointerUp { x: 103, y: 41 }); + assert!(input.snapshot().2.is_empty()); + } + + // Test helpers (avoid needing to construct full Events for key tests). + impl Input { + fn on_event_matches_key(&self, key: Key) -> u32 { + key_bit(key) + } + fn apply_key_down(&mut self, key: Key) { + self.buttons |= key_bit(key); + } + fn apply_key_up(&mut self, key: Key) { + self.buttons &= !key_bit(key); + } + } +} diff --git a/hosts/pocketbook/src/main.rs b/hosts/pocketbook/src/main.rs new file mode 100644 index 00000000..4e3e6d12 --- /dev/null +++ b/hosts/pocketbook/src/main.rs @@ -0,0 +1,279 @@ +//! pocketbook-host — the PocketJS UI runtime on PocketBook e-readers. +//! +//! Reuses the backend-agnostic `ui` surface (`pocket_ui_surface::UiSurface`) +//! and the core's software rasterizer, then converts RGBA8 → Gray8 and drives +//! the e-ink panel through inkview. See `pocketjs-inkview-implementation.md` +//! at the repo root for the full design and the ground-truth API notes. +//! +//! Event-loop model (mirrors `inkview-slint`): `iv_main` runs on the main +//! thread forwarding every `Event` into an mpsc channel; a second thread owns +//! the `Screen` and the PocketJS tick/render loop, pulling events with a +//! timeout so it ticks on a fixed cadence even when idle. + +mod framebuffer; +mod input; +mod refresh; + +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use inkview::Event; +use pocket_mod::Guest; +use pocket_ui_surface::UiSurface; + +use framebuffer::DirtyRect; + +/// Host platform-contract identity. Must match `pocketbook.hostAbi` in +/// contracts/spec/platforms.ts, or plan-built bundles refuse this host +/// (framework/src/host.ts::assertNativeHostContract). +const HOST_ID: &str = "pocketbook"; +const HOST_ABI: u32 = 4; + +/// Logical tick cadence. E-ink doesn't need 60 fps; ~30 fps keeps animations +/// smooth while sparing CPU and battery. +const TICK_MS: u64 = 33; + +/// Touch packs coordinates into 9 bits (framework/src/touch.ts), so the +/// logical viewport must stay ≤511 px per axis. +const MAX_LOGICAL_AXIS: usize = 511; + +fn main() -> Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + // &'static Inkview so both the render thread (Screen::new) and the main + // thread (iv_main) can reference it — same pattern as the slint demo. + let iv: &'static inkview::bindings::Inkview = Box::leak(Box::new(inkview::load())); + + let (tx, rx) = mpsc::channel::(); + + let render = std::thread::spawn(move || run(iv, rx)); + + // Main thread: the inkview event loop. Forward every event to the render + // thread; if it has gone away (send fails), ask inkview to close the app. + inkview::iv_main(iv, move |ev| { + if tx.send(ev).is_err() { + // SAFETY: the render thread owns the only other use of `iv` and has + // dropped its receiver (send failed), so it is quiescent; CloseApp + // only asks inkview to tear down its main loop. Mirrors the + // inkview-slint demo's shutdown path. + unsafe { + iv.CloseApp(); + } + } + Some(()) + }); + + render + .join() + .map_err(|_| anyhow::anyhow!("render thread panicked"))? +} + +/// The render thread: boot the guest, then tick/render until Exit. +fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver) -> Result<()> { + // inkview delivers Init first; wait for it before touching the framebuffer. + if rx.recv().context("event channel closed before Init")? != Event::Init { + anyhow::bail!("expected EVT_INIT first"); + } + + let mut screen = inkview::screen::Screen::new(iv); + let phys_w = screen.width(); + let phys_h = screen.height(); + + let geo = Geometry::choose(phys_w, phys_h); + log::info!( + "pocketbook: panel {phys_w}x{phys_h}, logical {}x{} @{}x, render {}x{} +({},{})", + geo.logical_w, + geo.logical_h, + geo.density, + geo.render_w, + geo.render_h, + geo.ox, + geo.oy + ); + + // Boot the guest exactly like uihost: feed pak, mount ui, eval bundle. + let pak = std::fs::read(pak_path()).with_context(|| format!("reading {}", pak_path()))?; + let bundle = + std::fs::read_to_string(js_path()).with_context(|| format!("reading {}", js_path()))?; + + let surface = UiSurface::new_with_density( + (geo.logical_w as f32, geo.logical_h as f32), + geo.density, + ); + surface.set_identity(HOST_ID, HOST_ABI); + surface.feed_pak(&pak); + + let guest = Guest::new()?; + surface.mount(&guest)?; + guest.eval("app", &bundle)?; + anyhow::ensure!( + guest.has_frame(), + "bundle installed no frame() — is this a PocketJS app?" + ); + + let mut fb = framebuffer::FramebufferPipeline::new(geo.render_w, geo.render_h, geo.density); + let mut refresh = refresh::Refresh::new(); + let mut input = input::Input::new( + geo.density, + geo.ox as i32, + geo.oy as i32, + geo.logical_w as u32, + geo.logical_h as u32, + ); + + // First paint: render one frame and full-update so the screen starts clean. + tick( + &guest, &surface, &mut fb, &mut refresh, &mut input, &mut screen, &geo, true, + )?; + + let mut last_tick = Instant::now(); + loop { + // Pull events until the tick deadline, then drain any burst. + let deadline = last_tick + Duration::from_millis(TICK_MS); + let mut quit = false; + let mut full = false; + loop { + let now = Instant::now(); + if now >= deadline { + break; + } + match rx.recv_timeout(deadline - now) { + Ok(ev) => match input.on_event(ev) { + input::Outcome::Quit => { + quit = true; + break; + } + input::Outcome::FullRedraw => full = true, + input::Outcome::Continue => {} + }, + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => { + quit = true; + break; + } + } + } + while let Ok(ev) = rx.try_recv() { + match input.on_event(ev) { + input::Outcome::Quit => quit = true, + input::Outcome::FullRedraw => full = true, + input::Outcome::Continue => {} + } + } + if quit { + break; + } + + last_tick = Instant::now(); + tick( + &guest, &surface, &mut fb, &mut refresh, &mut input, &mut screen, &geo, full, + )?; + } + Ok(()) +} + +/// One fixed-step frame: guest turn → core tick → draw → raster → gray → blit +/// → panel update. Order matches uihost. +#[allow(clippy::too_many_arguments)] +fn tick( + guest: &Guest, + surface: &UiSurface, + fb: &mut framebuffer::FramebufferPipeline, + refresh: &mut refresh::Refresh, + input: &mut input::Input, + screen: &mut inkview::screen::Screen, + geo: &Geometry, + full: bool, +) -> Result<()> { + let (buttons, analog, touches) = input.snapshot(); + guest.frame_with_touches(buttons, analog, &touches)?; + + surface.tick(); + + // Rasterize at density → RGBA8, then luminance → Gray8 + damage (buffer coords). + let dirty = surface.with_ui(|ui| { + let words = ui.draw().words.clone(); + fb.rasterize(ui, &words); + fb.convert_and_diff() + }); + + if full { + fb.blit_all(screen, geo.ox, geo.oy); + refresh.full(screen); + return Ok(()); + } + + if !dirty.is_empty() { + fb.blit_dirty(screen, &dirty, geo.ox, geo.oy); + let screen_dirty = offset_rects(&dirty, geo.ox, geo.oy); + refresh.present(screen, &screen_dirty); + } else { + // No change this frame; still let the refresh policy run its quiet + // cleanup timer (it no-ops when there's nothing pending). + refresh.present(screen, &[]); + } + Ok(()) +} + +/// Render-buffer rects → screen rects (integer-fit origin offset). +fn offset_rects(rects: &[DirtyRect], ox: usize, oy: usize) -> Vec { + rects + .iter() + .map(|r| DirtyRect { + x: r.x + ox, + y: r.y + oy, + w: r.w, + h: r.h, + }) + .collect() +} + +/// Logical-viewport / raster-density geometry for the current panel. +struct Geometry { + logical_w: usize, + logical_h: usize, + density: u32, + render_w: usize, + render_h: usize, + ox: usize, + oy: usize, +} + +impl Geometry { + /// Pick a raster density (env `POCKET_DENSITY`, default 2) and the largest + /// logical viewport that (a) integer-scales by the density onto the panel + /// and (b) stays ≤511 px/axis so touch coordinates fit the 9-bit wire + /// format. The render buffer is integer-fit centered on the panel. + fn choose(phys_w: usize, phys_h: usize) -> Self { + let density = std::env::var("POCKET_DENSITY") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(2) + .clamp(1, 4); + let d = density as usize; + let logical_w = (phys_w / d).clamp(1, MAX_LOGICAL_AXIS); + let logical_h = (phys_h / d).clamp(1, MAX_LOGICAL_AXIS); + let render_w = logical_w * d; + let render_h = logical_h * d; + let ox = phys_w.saturating_sub(render_w) / 2; + let oy = phys_h.saturating_sub(render_h) / 2; + Self { + logical_w, + logical_h, + density, + render_w, + render_h, + ox, + oy, + } + } +} + +fn pak_path() -> String { + std::env::var("POCKET_PAK").unwrap_or_else(|_| "app.pak".into()) +} + +fn js_path() -> String { + std::env::var("POCKET_JS").unwrap_or_else(|_| "app.js".into()) +} diff --git a/hosts/pocketbook/src/refresh.rs b/hosts/pocketbook/src/refresh.rs new file mode 100644 index 00000000..90ae8905 --- /dev/null +++ b/hosts/pocketbook/src/refresh.rs @@ -0,0 +1,130 @@ +//! E-ink panel update policy. +//! +//! Ported from the battle-tested strategy in `inkview-slint/src/lib.rs`: gate +//! on `Screen::is_updating()`; issue a high-quality `partial_update` on the +//! damage box while the panel is idle; while an update is in flight, throttle +//! fast `dynamic_update`s (≥20 ms apart) on the accumulated damage; and after +//! ~200 ms of quiet, do a final `partial_update` over the dynamic-updated +//! region to clear ghosting. + +use std::time::{Duration, Instant}; + +use inkview::screen::Screen; + +use crate::framebuffer::DirtyRect; + +const DYNAMIC_MIN_INTERVAL: Duration = Duration::from_millis(20); +const CLEANUP_QUIET: Duration = Duration::from_millis(200); + +#[derive(Clone, Copy)] +struct Rect { + x: i32, + y: i32, + w: u32, + h: u32, +} + +pub struct Refresh { + last_draw: Instant, + /// Damage accumulated while a panel update was in flight; needs a cleanup + /// partial update once things go quiet. + pending_cleanup: Option, + cleanup_after: Option, +} + +impl Refresh { + pub fn new() -> Self { + Self { + last_draw: Instant::now(), + pending_cleanup: None, + cleanup_after: None, + } + } + + /// Drive the panel for this frame. `dirty` is in SCREEN coordinates + /// (render-buffer rects offset by the integer-fit origin). + pub fn present(&mut self, screen: &mut Screen, dirty: &[DirtyRect]) { + // Quiet-period cleanup: a final high-quality partial update on the + // region we hammered with dynamic updates (clears ghosting). + if let Some(at) = self.cleanup_after { + if Instant::now() >= at { + if let Some(r) = self.pending_cleanup.take() { + screen.partial_update(r.x, r.y, r.w, r.h); + self.last_draw = Instant::now(); + } + self.cleanup_after = None; + } + } + + if dirty.is_empty() { + return; + } + let d = merge(dirty); + + if screen.is_updating() { + // A panel update is still in flight. Queue a fast dynamic update, + // throttled to ≥20 ms, on the accumulated damage; schedule a + // cleanup partial update 200 ms after the last draw. + self.pending_cleanup = Some(union(self.pending_cleanup, d)); + if self.last_draw.elapsed() > DYNAMIC_MIN_INTERVAL { + let r = self.pending_cleanup.unwrap(); + screen.dynamic_update(r.x, r.y, r.w, r.h); + self.last_draw = Instant::now(); + } + self.cleanup_after = Some(Instant::now() + CLEANUP_QUIET); + } else { + // Idle panel: high-quality non-flashing partial on the damage box. + screen.partial_update(d.x, d.y, d.w, d.h); + self.last_draw = Instant::now(); + } + } + + /// Full flashing redraw — call on Show / orientation change / mode switch. + pub fn full(&mut self, screen: &mut Screen) { + screen.full_update(); + self.last_draw = Instant::now(); + self.pending_cleanup = None; + self.cleanup_after = None; + } +} + +impl Default for Refresh { + fn default() -> Self { + Self::new() + } +} + +fn merge(rects: &[DirtyRect]) -> Rect { + let (mut x0, mut y0) = (i32::MAX, i32::MAX); + let (mut x1, mut y1) = (0i32, 0i32); + for r in rects { + x0 = x0.min(r.x as i32); + y0 = y0.min(r.y as i32); + x1 = x1.max((r.x + r.w) as i32); + y1 = y1.max((r.y + r.h) as i32); + } + Rect { + x: x0, + y: y0, + w: (x1 - x0) as u32, + h: (y1 - y0) as u32, + } +} + +fn union(a: Option, b: Rect) -> Rect { + match a { + None => b, + Some(a) => { + let x0 = a.x.min(b.x); + let y0 = a.y.min(b.y); + let x1 = (a.x + a.w as i32).max(b.x + b.w as i32); + let y1 = (a.y + a.h as i32).max(b.y + b.h as i32); + Rect { + x: x0, + y: y0, + w: (x1 - x0) as u32, + h: (y1 - y0) as u32, + } + } + } +} From 19d84e870dd1b3630b68af3784b28d6745ff90bf Mon Sep 17 00:00:00 2001 From: Alberto Date: Thu, 23 Jul 2026 21:15:12 +0200 Subject: [PATCH 04/13] docs: add PocketBook inkview integration + implementation guides The integration guide is the feasibility study; the implementation guide turns it into a buildable plan grounded in the actual pocketjs and inkview-rs APIs (corrections table, revised architecture, per-module code, the touch/viewport design decision, and a phased checklist). --- pocketjs-inkview-implementation.md | 874 +++++++++++++++++ pocketjs-inkview-integration.md | 1398 ++++++++++++++++++++++++++++ 2 files changed, 2272 insertions(+) create mode 100644 pocketjs-inkview-implementation.md create mode 100644 pocketjs-inkview-integration.md diff --git a/pocketjs-inkview-implementation.md b/pocketjs-inkview-implementation.md new file mode 100644 index 00000000..f2dd4b22 --- /dev/null +++ b/pocketjs-inkview-implementation.md @@ -0,0 +1,874 @@ +# PocketJS × inkview — Implementation File + +> **Companion to** `pocketjs-inkview-integration.md` (the feasibility study). +> This file turns that study into a concrete, buildable plan grounded in the +> **actual** APIs of `pocketjs` (this repo) and `inkview-rs` +> (`/var/home/alberto/projects/inkview-rs`). +> +> Every code sketch below was checked against the real source. Where the +> feasibility doc sketched an API from memory, it was frequently wrong — +> **read §0 first**; it lists the corrections that change the shape of the +> implementation. + +--- + +## 0. Ground-Truth Corrections (read this first) + +The feasibility doc is directionally right but several of its API sketches do +not match the code. These corrections reshape the implementation: + +| # | Feasibility doc says | Reality (verified) | Consequence | +| --- | ---------------------- | -------------------- | ------------- | +| 1 | Hand-write ~17 `HostOps` forwarders in a new `host_ops.rs` / `ffi.rs` using a `pocket-mod` API of `ns.func(...)`, `args[0].as_i32()` | The **full** `ui` surface already exists: `pocket_ui_wgpu::UiSurface` (`engine/crates/pocket-ui-wgpu/src/surface.rs`) implements every op, pak feeding, boot tables, and host identity. `pocket-mod`'s real mount API is rquickjs: `guest.mount("ui", \|ctx, ns\| ns.set("op", Function::new(ctx.clone(), closure)))` with **typed closure params**. | **Do not reimplement the surface.** Reuse `UiSurface`. The entire `host_ops.rs`/`ffi.rs` from the doc is deleted. | +| 2 | Pak is a *directory*: `bundle.js`, `styles.bin`, `font-atlas-*.bin`, `images/` | A pak is a **single binary archive** walked by `walk_pak` (`engine/crates/pocket-ui-wgpu/src/pak.rs`, format pinned in `spec::pak`). The JS bundle is a **separate `.js` file**. `UiSurface::feed_pak(&bytes)` feeds styles/atlases/images/sprites natively. | `load_pak` in the doc is deleted. Load one `.pak` + one `.js`. | +| 3 | `ui.tick(1.0/60.0)` and `let words = ui.draw() -> Vec` | `Ui::tick(&mut self)` takes **no dt** (fixed step internally). `Ui::draw(&mut self) -> &DrawList`, and `DrawList { pub words: Vec }`. | Call `surface.tick()`; rasterize from `ui.draw().words`. | +| 4 | `frame(buttons, analog?, touches?)` is called via `guest.call_frame(buttons, analog, touches)` | `pocket-mod::Guest` only exposes `frame(buttons)` and `frame_with_analog(buttons, analog)` — **no touch path**. The framework *does* accept a 3rd `touches: readonly number[]` arg (`framework/src/host.ts:306`, decoded in `framework/src/touch.ts`). | Add a small `Guest::frame_with_touches(buttons, analog, &[u32])` to `pocket-mod` (§6). | +| 5 | Touch is "single-touch `{x,y,phase}`", coords scaled freely | Touch packs each contact as one u32: `(id<<18) \| (y<<9) \| x` — **9 bits per axis ⇒ max coordinate 511** (`framework/src/touch.ts`). There is no `phase`; a contact is simply present (down) or absent (up) per frame. | **The logical viewport must be ≤ 511×511.** A 1024×758 logical viewport (doc's "1:1 is best") silently wraps touch X. This forces the Vita-style scaled model (§9). This is the single biggest architectural correction. | +| 6 | Drive ticks with inkview `SetHardTimer` inside `iv_main`, state in `Arc>` | `SetHardTimer` is **not** in inkview's safe API. The proven pattern (`inkview-slint`) runs `iv_main` on the **main thread forwarding events into an mpsc channel**, and a **second thread** owns the `Screen` + a pull-based loop (`recv_timeout`/`try_recv`) that ticks and renders. `&'static Inkview` via `Box::leak`. | Use the channel + render-thread model (§4). No `Arc>` around the hot path; single-threaded `Rc>` inside the render thread. | +| 7 | A bespoke `RefreshManager` with `full_update_interval`, `quiet_period_ms`, etc. | `inkview-slint/src/lib.rs` ships a **battle-tested** strategy: gate on `screen.is_updating()`; `partial_update` on the damage box when idle; `dynamic_update` (throttled to ≥20 ms) on accumulated damage while a update is in flight; a final `partial_update` after ~200 ms quiet to clear ghosting. | Base `refresh.rs` on the slint strategy, not the speculative one (§7). | +| 8 | RGBA8→Gray8 with "alpha compositing over white" | `raster::render`/`render_scaled` output **RGBA8, cleared to opaque black, every pixel alpha=255** (`engine/core/src/raster.rs` `blend_px`). No compositing needed. `render_scaled(ui, words, fb, scale)` renders at `viewport×scale` physical px (scale 1..4). | Gray conversion is a straight luminance read; background is black unless the app paints white (§5, §11). | +| 9 | `inkview = "0.3"` from crates.io; `load()` returns `Result`; `Screen::draw(x: i32, …)` | `inkview::load() -> bindings::Inkview` (panics on failure, not `Result`). `Screen::new(&Inkview)`. `draw(x: usize, y: usize, BB8)`. `partial_update(x: i32, y: i32, w: u32, h: u32)`, `dynamic_update(…)`, `full_update()`, `fast_update()` (= `SoftUpdate`), `is_updating() -> bool`, `width()/height()/dpi()/scale()`. `Event` has `Init, Show, Repaint, Hide, Exit, Foreground, Background, KeyDown, KeyRepeat, KeyUp, PointerDown/Move/Up`. | Use a git/path dep during development; call the real signatures (§3, §4). | + +--- + +## 1. Revised Architecture + +``` +app.tsx (Solid / Vue Vapor + Tailwind) + │ bun tools/build.ts --target pocketbook + ▼ +app.js + app.pak ← single binary pak (styles, atlases, images, sprites) + │ + ▼ (loaded by the host at startup) +┌───────────────────────────────────────────────────────────────┐ +│ QuickJS guest (pocket_mod::Guest) │ +│ globalThis.ui ← UiSurface (REUSED from pocket-ui-surface) │ +│ globalThis.frame(buttons, analog, touches) │ +└───────────────────────────┬───────────────────────────────────┘ + │ ui.* ops + ▼ +┌───────────────────────────────────────────────────────────────┐ +│ pocketjs_core::Ui (inside UiSurface) │ +│ feed_pak → load_styles / load_font_atlas / upload_texture │ +│ tick() → draw() → DrawList { words: Vec } │ +└───────────────────────────┬───────────────────────────────────┘ + │ raster::render_scaled(ui, words, fb, density) + ▼ RGBA8 @ physical res (logical × density) +┌───────────────────────────────────────────────────────────────┐ +│ hosts/pocketbook (NEW) │ +│ framebuffer.rs : RGBA8 → Gray8 + tile damage │ +│ refresh.rs : is_updating gate → partial/dynamic/full │ +│ input.rs : inkview Event → BTN bitmask + packed touch │ +│ main.rs : channel event loop + render thread │ +└───────────────────────────┬───────────────────────────────────┘ + │ screen.draw(x,y,BB8) + partial/dynamic/full_update + ▼ + libinkview.so → PocketBook e-ink panel +``` + +**The new code is small** because the heavy lifting (UI tree, layout, style, +animation, DrawList, software rasterizer, the entire `ui` HostOps surface, pak +feeding) already exists and is reused unchanged. The host is essentially: +*event loop glue + RGBA8→Gray8 + e-ink refresh policy + input mapping.* + +--- + +## 2. One prerequisite refactor: extract `UiSurface` from `pocket-ui-wgpu` + +`UiSurface` currently lives in `pocket-ui-wgpu`, which also pulls in `wgpu`. +Depending on it from the PocketBook host would drag the whole GPU stack into an +ARM cross-build that never uses it. Extract the backend-agnostic surface into +its own crate. + +**New crate `engine/crates/pocket-ui-surface/`** containing the three modules +that have no wgpu dependency: + +| Move from `pocket-ui-wgpu/src/` | To `pocket-ui-surface/src/` | Notes | +| --- | --- | --- | +| `surface.rs` | `surface.rs` | `UiSurface`; only depends on `pocketjs_core`, `pocket_mod`, `crate::pak`, `crate::dbg` | +| `pak.rs` | `pak.rs` | `walk_pak`, `find_pak`, `PakEntry` | +| `dbg.rs` | `dbg.rs` | `DbgMailbox` (used by the DevTools ops in `surface.rs`) | + +Then: + +- `pocket-ui-wgpu` keeps `blit.rs` + `render.rs` (`UiRenderer`), re-exports + `UiSurface`/`walk_pak` from `pocket-ui-surface` so `uihost` and OpenStrike are + source-compatible (`pub use pocket_ui_surface::{UiSurface, walk_pak, PakEntry};`). +- `hosts/pocketbook` depends on `pocket-ui-surface` (no wgpu). + +This is a pure move + re-export; no behavior changes, and it keeps the desktop +host building exactly as before. (A first spike can skip this and depend on +`pocket-ui-wgpu` directly to validate the pipeline, but the extraction should +land before the host is merged so the ARM build stays lean.) + +--- + +## 3. Crate layout & manifest + +``` +hosts/pocketbook/ +├── Cargo.toml +└── src/ + ├── main.rs # channel event loop, boot, render thread, tick + ├── input.rs # inkview Event → BTN bitmask + packed touch contacts + ├── framebuffer.rs # render_scaled RGBA8 → Gray8, tile-based damage + └── refresh.rs # e-ink update policy (slint-style) +``` + +```toml +# hosts/pocketbook/Cargo.toml +[package] +name = "pocketbook-host" +version = "0.1.0" +edition = "2021" + +[dependencies] +pocketjs-core = { path = "../../engine/core" } +pocket-mod = { path = "../../engine/crates/pocket-mod" } +pocket-ui-surface = { path = "../../engine/crates/pocket-ui-surface" } # §2 +anyhow = "1" +log = "0.4" +env_logger = "0.11" + +# inkview is not assumed to be on crates.io during development. Use the local +# checkout or the upstream git repo; pick ONE: +inkview = { path = "../../../inkview-rs/inkview" } +# inkview = { git = "https://github.com/simmsb/inkview-rs", package = "inkview" } +# Default feature is sdk-6-10; override with --no-default-features --features sdk-6-5 etc. + +[profile.release] +opt-level = "s" +lto = true +strip = true +panic = "abort" +``` + +Add the crate to the `engine/Cargo.toml` workspace `members` (the host lives +under `hosts/` but shares the engine workspace so path deps resolve). + +### Cross-compilation + +PocketBook runs ARM Linux, glibc 2.23. Same recipe inkview-rs uses: + +```bash +rustup target add armv7-unknown-linux-gnueabi +cargo install cargo-zigbuild + +cd hosts/pocketbook +cargo zigbuild --release --target armv7-unknown-linux-gnueabi.2.23 +# libinkview.so is dlopen'd at runtime (inkview::load) — no SDK at build time. +``` + +--- + +## 4. `main.rs` — channel event loop + render thread + +This mirrors `inkview-rs/examples/inkview-slint-demo/src/main.rs`: `iv_main` +runs on the main thread and forwards every `Event` into an mpsc channel; a +second thread owns the `Screen` and the PocketJS tick/render loop, pulling +events with a timeout so it can tick on a cadence even when idle. + +```rust +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use inkview::Event; +use pocket_mod::Guest; +use pocket_ui_surface::UiSurface; + +mod framebuffer; +mod input; +mod refresh; + +// Logical viewport + raster density. See §9 for why logical must be ≤511/axis. +// Vita-shaped default; finalize per-device on hardware. +const LOGICAL_W: u32 = 480; +const LOGICAL_H: u32 = 320; // 480×320 @ density 2 → 960×640, integer-fit on panel +const DENSITY: u32 = 2; +const TICK_MS: u64 = 33; // ~30 fps logical tick; e-ink doesn't need 60 + +fn main() -> Result<()> { + env_logger::init(); + + // &'static Inkview: leak it so both threads can reference it (single-threaded + // inkview callbacks only touch it from the main thread; Screen::new reads it + // once on the render thread at startup — matches the slint demo). + let iv: &'static inkview::bindings::Inkview = Box::leak(Box::new(inkview::load())); + + let (tx, rx) = mpsc::channel::(); + + // Render thread: owns Screen + guest + core + pipeline. + let render = std::thread::spawn(move || -> Result<()> { + // The first event inkview delivers is Init; wait for it before touching + // the framebuffer (the slint demo asserts the same ordering). + if rx.recv().context("event channel closed before Init")? != Event::Init { + anyhow::bail!("expected EVT_INIT first"); + } + + let mut screen = inkview::screen::Screen::new(iv); + let phys_w = screen.width(); + let phys_h = screen.height(); + + // Boot the guest exactly like uihost (engine/pocket3d/examples/uihost): + let pak = std::fs::read(pak_path()).context("read app.pak")?; + let bundle = std::fs::read_to_string(js_path()).context("read app.js")?; + + let surface = UiSurface::new_with_density((LOGICAL_W as f32, LOGICAL_H as f32), DENSITY); + surface.set_identity("pocketbook", HOST_ABI); // §8 — must match platforms.ts + surface.feed_pak(&pak); + + let guest = Guest::new()?; + surface.mount(&guest)?; + guest.eval("app", &bundle)?; + anyhow::ensure!(guest.has_frame(), "bundle installed no frame()"); + + let mut fb = framebuffer::FramebufferPipeline::new(phys_w, phys_h); + let mut refresh = refresh::Refresh::new(); + let mut input = input::Input::new(LOGICAL_W, LOGICAL_H, phys_w, phys_h); + + let mut last_tick = Instant::now(); + let mut running = true; + + while running { + // Pull events until the tick deadline; drain everything pending. + let deadline = last_tick + Duration::from_millis(TICK_MS); + loop { + let now = Instant::now(); + if now >= deadline { break; } + match rx.recv_timeout(deadline - now) { + Ok(ev) => { + if input.on_event(ev) == input::Outcome::Quit { running = false; } + } + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => { running = false; break; } + } + } + // Drain any burst that arrived (non-blocking). + while let Ok(ev) = rx.try_recv() { + if input.on_event(ev) == input::Outcome::Quit { running = false; } + } + if !running { break; } + + last_tick = Instant::now(); + tick(&guest, &surface, &mut fb, &mut refresh, &mut input, &mut screen)?; + } + Ok(()) + }); + + // Main thread: the inkview event loop. Forward everything; return Some(()) + // for events we consume so inkview doesn't also run its default handling. + inkview::iv_main(iv, move |ev| { + let _ = tx.send(ev); + match ev { + Event::Exit => None, // let inkview tear down + _ => Some(()), + } + }); + + render.join().unwrap() +} + +/// One fixed-step frame. Order matches uihost: guest turn → core tick → draw → +/// rasterize → gray → blit → refresh. +fn tick( + guest: &Guest, + surface: &UiSurface, + fb: &mut framebuffer::FramebufferPipeline, + refresh: &mut refresh::Refresh, + input: &mut input::Input, + screen: &mut inkview::screen::Screen, +) -> Result<()> { + // 1. Guest turn with buttons + analog + packed touches (§6). + let (buttons, analog, touches) = input.snapshot(); + guest.frame_with_touches(buttons, analog, &touches)?; + + // 2. Core fixed step + DrawList. + surface.tick(); + + // 3. Rasterize at physical resolution (logical × density) → RGBA8. + let dirty = surface.with_ui(|ui| { + let words = ui.draw().words.clone(); // borrow ends before fb write + fb.rasterize(ui, &words); // render_scaled into fb.rgba + fb.to_gray_and_diff() // RGBA8→Gray8 + tile damage + }); + + // 4. Blit changed pixels, then let the refresh policy drive the panel. + if !dirty.is_empty() { + fb.blit_dirty(screen, &dirty); + } + refresh.present(screen, &dirty); + Ok(()) +} +``` + +Notes: + +- `UiSurface::with_ui(|ui| …)` gives `&mut Ui` for `draw()`; we clone `words` + out so the immutable borrow ends before `fb` (which needs `&Ui` for + `render_scaled`) — or restructure to rasterize inside the same closure + (preferred; `render_scaled(ui, &words, …)` can run while `ui` is borrowed). +- `HOST_ABI`, `pak_path()`, `js_path()` are constants/helpers (§8, §10). + +--- + +## 5. `framebuffer.rs` — RGBA8 → Gray8 + damage + +```rust +use inkview::screen::{BB8, Screen}; +use pocketjs_core::{Ui, raster}; + +#[derive(Clone, Copy, Debug)] +pub struct DirtyRect { pub x: usize, pub y: usize, pub w: usize, pub h: usize } + +const TILE: usize = 16; + +pub struct FramebufferPipeline { + rgba: Vec, // physical W×H×4, RGBA8 (raster::render_scaled output) + gray: Vec, // physical W×H Gray8 (current) + prev: Vec, // physical W×H Gray8 (previous, for damage) + w: usize, + h: usize, +} + +impl FramebufferPipeline { + pub fn new(w: usize, h: usize) -> Self { + let n = w * h; + Self { + rgba: vec![0u8; n * 4], + gray: vec![255u8; n], // start white (e-ink idle = white) + prev: vec![255u8; n], + w, h, + } + } + + /// Rasterize the DrawList at physical resolution. `scale` = raster density. + /// Output is RGBA8, cleared to opaque black, all pixels alpha=255. + pub fn rasterize(&mut self, ui: &Ui, words: &[u32]) { + // render_scaled asserts fb.len() == vw*scale × vh*scale × 4. + raster::render_scaled(ui, words, &mut self.rgba, crate::DENSITY); + } + + /// Luminance-convert RGBA8→Gray8 and return changed 16×16 tiles. + pub fn to_gray_and_diff(&mut self) -> Vec { + let n = self.w * self.h; + for i in 0..n { + let r = self.rgba[i * 4] as u32; + let g = self.rgba[i * 4 + 1] as u32; + let b = self.rgba[i * 4 + 2] as u32; + // Same coefficients inkview uses (screen.rs RGB24::to_bb8). + // Integer form of 0.2125R + 0.7154G + 0.0721B: + self.gray[i] = ((54 * r + 183 * g + 19 * b) >> 8) as u8; + } + let dirty = self.diff(); + std::mem::swap(&mut self.gray, &mut self.prev); + dirty + } + + fn diff(&self) -> Vec { + let (w, h) = (self.w, self.h); + let tx = w.div_ceil(TILE); + let ty = h.div_ceil(TILE); + let mut flags = vec![false; tx * ty]; + for y in 0..h { + let row = y * w; + for x in 0..w { + let i = row + x; + if self.gray[i] != self.prev[i] { + flags[(y / TILE) * tx + (x / TILE)] = true; + } + } + } + flags.iter().enumerate() + .filter(|(_, d)| **d) + .map(|(i, _)| { + let px = (i % tx) * TILE; + let py = (i / tx) * TILE; + DirtyRect { x: px, y: py, w: TILE.min(w - px), h: TILE.min(h - py) } + }) + .collect() + } + + /// Write only changed tiles to the inkview framebuffer (no panel update yet). + pub fn blit_dirty(&self, screen: &mut Screen, dirty: &[DirtyRect]) { + for r in dirty { + for y in r.y..(r.y + r.h) { + for x in r.x..(r.x + r.w) { + screen.draw(x, y, BB8(self.gray[y * self.w + x])); + } + } + } + } + + /// Full-screen blit (used on Show / orientation change before a full_update). + pub fn blit_all(&self, screen: &mut Screen) { + for y in 0..self.h { + for x in 0..self.w { + screen.draw(x, y, BB8(self.gray[y * self.w + x])); + } + } + } +} +``` + +Optimizations (later, §13 of the feasibility doc): merge adjacent dirty tiles +into fewer larger rects; NEON the luminance loop; a direct Gray8 rasterizer in +`raster.rs`. None are needed for a correct MVP — `screen.draw` is a bounds-checked +byte write and the panel update dominates latency by orders of magnitude. + +--- + +## 6. Touch + the `pocket-mod` addition + +The framework decodes a 3rd `frame` argument (`framework/src/touch.ts`): each +contact is one u32 `(id<<18) | (y<<9) | x`, **9 bits per axis (max 511)**, up to +8 contacts. A contact present = down/move; absent = released. `pocket-mod` +currently has no 3-arg frame, so add one: + +```rust +// engine/crates/pocket-mod/src/lib.rs — add alongside frame_with_analog +/// One guest turn with touch contacts. `touches` packs each contact as +/// (id<<18)|(y<<9)|x (framework/src/touch.ts); coords are logical px, ≤511/axis. +pub fn frame_with_touches(&self, buttons: u32, analog: u32, touches: &[u32]) -> Result<()> { + self.ctx.with(|ctx| -> Result<()> { + let frame: Option = ctx.globals().get("frame").ok(); + if let Some(frame) = frame { + let arr = rquickjs::Array::new(ctx.clone()) + .map_err(|e| anyhow!("pocket-mod: touch array: {e}"))?; + for (i, t) in touches.iter().enumerate() { + arr.set(i, *t) + .map_err(|e| anyhow!("pocket-mod: touch set: {e}"))?; + } + frame + .call::<_, ()>((buttons, analog, arr)) + .catch(&ctx) + .map_err(|e| anyhow!("pocket-mod: frame() threw: {e}"))?; + } + Ok(()) + })?; + self.drain_jobs(); + Ok(()) +} +``` + +This is additive and leaves `frame`/`frame_with_analog` (and every existing +host, tape, and golden) untouched. + +--- + +## 7. `refresh.rs` — e-ink update policy (slint-derived) + +Ported from `inkview-slint/src/lib.rs`'s proven loop, expressed as a small state +machine the host calls once per tick with the frame's damage: + +```rust +use std::time::{Duration, Instant}; +use inkview::screen::Screen; +use crate::framebuffer::DirtyRect; + +pub struct Refresh { + last_draw: Instant, + /// Damage accumulated while a panel update was in flight (needs a cleanup + /// partial update once things go quiet). + pending_cleanup: Option, + cleanup_after: Option, +} + +#[derive(Clone, Copy)] +struct Rect { x: i32, y: i32, w: u32, h: u32 } + +impl Refresh { + pub fn new() -> Self { + Self { last_draw: Instant::now(), pending_cleanup: None, cleanup_after: None } + } + + pub fn present(&mut self, screen: &mut Screen, dirty: &[DirtyRect]) { + // 1. Quiet-period cleanup: a final high-quality partial update on the + // region we hammered with dynamic updates (clears ghosting). + if let Some(at) = self.cleanup_after { + if Instant::now() >= at { + if let Some(r) = self.pending_cleanup.take() { + screen.partial_update(r.x, r.y, r.w, r.h); + self.last_draw = Instant::now(); + } + self.cleanup_after = None; + } + } + + if dirty.is_empty() { return; } + let d = merge(dirty); + + if screen.is_updating() { + // A panel update is still in flight. Don't queue a high-quality + // partial (it stalls); instead do a fast dynamic update, throttled + // to ≥20 ms, on the accumulated damage — exactly the slint policy. + self.pending_cleanup = Some(union(self.pending_cleanup, d)); + if self.last_draw.elapsed() > Duration::from_millis(20) { + let r = self.pending_cleanup.unwrap(); + screen.dynamic_update(r.x, r.y, r.w, r.h); + self.last_draw = Instant::now(); + } + // Schedule a cleanup partial update 200 ms after the last draw. + self.cleanup_after = Some(Instant::now() + Duration::from_millis(200)); + } else { + // Idle panel: high-quality non-flashing partial on the damage box. + screen.partial_update(d.x, d.y, d.w, d.h); + self.last_draw = Instant::now(); + } + } + + /// Full flashing redraw — call on Show / orientation change / mode switch. + pub fn full(&mut self, screen: &mut Screen) { + screen.full_update(); + self.last_draw = Instant::now(); + self.pending_cleanup = None; + self.cleanup_after = None; + } +} + +fn merge(rects: &[DirtyRect]) -> Rect { + let (mut x0, mut y0) = (i32::MAX, i32::MAX); + let (mut x1, mut y1) = (0i32, 0i32); + for r in rects { + x0 = x0.min(r.x as i32); y0 = y0.min(r.y as i32); + x1 = x1.max((r.x + r.w) as i32); y1 = y1.max((r.y + r.h) as i32); + } + Rect { x: x0, y: y0, w: (x1 - x0) as u32, h: (y1 - y0) as u32 } +} + +fn union(a: Option, b: Rect) -> Rect { + match a { + None => b, + Some(a) => { + let x0 = a.x.min(b.x); let y0 = a.y.min(b.y); + let x1 = (a.x + a.w as i32).max(b.x + b.w as i32); + let y1 = (a.y + a.h as i32).max(b.y + b.h as i32); + Rect { x: x0, y: y0, w: (x1 - x0) as u32, h: (y1 - y0) as u32 } + } + } +} +``` + +`input::Input::on_event` should also call `refresh.full(screen)` on +`Event::Show` (returning from background) — wire that in `main.rs`. + +--- + +## 8. `input.rs` — keys → BTN bits, pointer → packed touch + +Button bits are the spec BTN constants (verified against +`engine/pocket3d/examples/uihost/src/main.rs` and `contracts/spec/spec.ts`): + +```rust +use inkview::event::{Event, Key}; +use pocketjs_core::spec::ANALOG_CENTER; // 0x8080 + +// spec BTN bits: +const BTN_SELECT: u32 = 0x0001; +const BTN_START: u32 = 0x0008; +const BTN_UP: u32 = 0x0010; +const BTN_RIGHT: u32 = 0x0020; +const BTN_DOWN: u32 = 0x0040; +const BTN_LEFT: u32 = 0x0080; +const BTN_LTRIGGER: u32 = 0x0100; +const BTN_RTRIGGER: u32 = 0x0200; +const BTN_TRIANGLE: u32 = 0x1000; +const BTN_CIRCLE: u32 = 0x2000; +const BTN_CROSS: u32 = 0x4000; +const BTN_SQUARE: u32 = 0x8000; + +pub enum Outcome { Continue, Quit } + +pub struct Input { + buttons: u32, + /// Current contact in LOGICAL px (None = up). PocketBook is single-touch. + touch: Option<(u32, u32)>, + // physical→logical scale (physical px / logical px), per axis. + sx: f32, + sy: f32, +} + +impl Input { + pub fn new(lw: u32, lh: u32, pw: usize, ph: usize) -> Self { + Self { + buttons: 0, + touch: None, + // Integer-fit offset handling belongs here if the logical surface is + // centered on a larger panel (§9): subtract the letterbox origin + // before scaling. Shown without offset for clarity. + sx: pw as f32 / lw as f32, + sy: ph as f32 / lh as f32, + } + } + + pub fn on_event(&mut self, ev: Event) -> Outcome { + match ev { + Event::Exit => Outcome::Quit, + Event::KeyDown { key } | Event::KeyRepeat { key } => { + if key == Key::Back { /* app may map this; not Quit by default */ } + self.buttons |= key_bit(key); + Outcome::Continue + } + Event::KeyUp { key } => { self.buttons &= !key_bit(key); Outcome::Continue } + Event::PointerDown { x, y } | Event::PointerMove { x, y } => { + self.touch = Some(self.to_logical(x, y)); + Outcome::Continue + } + Event::PointerUp { .. } => { self.touch = None; Outcome::Continue } + _ => Outcome::Continue, + } + } + + fn to_logical(&self, x: i32, y: i32) -> (u32, u32) { + // Clamp to the 9-bit touch range the framework decodes (≤511). + let lx = ((x as f32 / self.sx) as i32).clamp(0, 511) as u32; + let ly = ((y as f32 / self.sy) as i32).clamp(0, 511) as u32; + (lx, ly) + } + + /// (buttons, analog, packed touches) for Guest::frame_with_touches. + pub fn snapshot(&self) -> (u32, u32, Vec) { + let touches = self.touch.map(|(x, y)| vec![pack_touch(0, x, y)]).unwrap_or_default(); + (self.buttons, ANALOG_CENTER, touches) + } +} + +/// framework/src/touch.ts __packTouch: (id<<18)|(y<<9)|x. +fn pack_touch(id: u32, x: u32, y: u32) -> u32 { + ((id & 0xff) << 18) | ((y & 0x1ff) << 9) | (x & 0x1ff) +} + +fn key_bit(key: Key) -> u32 { + match key { + Key::Up => BTN_UP, + Key::Down => BTN_DOWN, + Key::Left | Key::Prev | Key::Prev2 => BTN_LEFT, // page-turn = prev + Key::Right | Key::Next | Key::Next2 => BTN_RIGHT, // page-turn = next + Key::Ok => BTN_CROSS, + Key::Back => BTN_CIRCLE, + Key::Menu => BTN_START, + Key::Home => BTN_SELECT, + Key::Plus => BTN_RTRIGGER, + Key::Minus => BTN_LTRIGGER, + _ => 0, + } +} +``` + +The exact key→button mapping is app-domain tuning; the above gives a sensible +reader-oriented default (page-turn keys = left/right). + +--- + +## 9. The viewport / touch design decision (FLAGGED) + +This is the one genuinely open architectural question, and the feasibility doc +missed it. Two hard constraints collide on a PocketBook: + +1. **Touch wire format**: contacts pack into 9 bits per axis ⇒ **logical + viewport ≤ 511×511** (`framework/src/touch.ts`). The doc's recommended + "logical = physical, 1:1" (1024×758) would wrap touch X at 512. +2. **Raster scaling**: `raster::render_scaled` takes an **integer** scale 1..4, + and `UiSurface::new_with_density` pairs a logical viewport with a density so + baked font atlases/coverage stay crisp. + +So the host must use the **Vita model**: a sub-512 logical viewport rendered at +`density` to reach native resolution. The logical size is a per-device tuning +knob; constraints: + +- `logical_w ≤ 511`, `logical_h ≤ 511` +- `logical_w * density ≤ phys_w`, `logical_h * density ≤ phys_h` +- integer `density ∈ {1,2,3,4}` +- prefer `logical * density == phys` per axis (no letterbox); otherwise + integer-fit centered with a letterbox origin that `input.rs` must subtract. + +Worked examples: + +| Device (phys) | density | logical | logical×density | fit? | +| --- | --- | --- | --- | --- | +| 1024×758 (Touch Lux 3) | 2 | **512×379** | 1024×758 | exact, but 512 > 511 ❌ touch | +| 1024×758 | 2 | **480×355** | 960×710 | letterboxed (32px/24px) ✅ | +| 1024×758 | 2 | **510×379** | 1020×758 | 4px side letterbox ✅ | +| 1872×1404 (InkPad) | 4 | **468×351** | 1872×1404 | exact ✅ | + +**Recommendation:** default to a Vita-proportioned logical viewport (e.g. +`480×320` or `510×379`) at density 2, integer-fit centered, and make +`(logical_w, logical_h, density)` runtime-configurable (read from the pak +manifest or a host config) so each device model gets a tuned value. Finalize the +exact numbers **on hardware** — text crispness vs. letterboxing is a visual +trade-off that can't be settled off-device. The touch clamp in `input.rs` +(`.clamp(0, 511)`) is a safety net, not a substitute for choosing a legal +logical size. + +--- + +## 10. Pak loading & build integration + +**Loading** is trivial because `UiSurface::feed_pak` does the native feeding +(styles → `load_styles`, `ui:font.*` → `load_font_atlas`, `ui:img.*` → +`upload_texture` + `__textures`, `ui:sprite.*` → `__sprites`). The host only +reads two files: + +```rust +fn pak_path() -> String { + // Apps live under /mnt/ext1/applications// on device. + std::env::var("POCKET_PAK").unwrap_or_else(|_| "app.pak".into()) +} +fn js_path() -> String { + std::env::var("POCKET_JS").unwrap_or_else(|_| "app.js".into()) +} +``` + +**Target profile** — add `pocketbook` to `contracts/spec/platforms.ts`, shaped +exactly like `vita` (the other density-2 touch target): + +```typescript +// In POCKET_TARGETS (and add `readonly pocketbook: TargetProfile<…>` to the type): +pocketbook: { + hostAbi: 4, // next free ABI (psp=1, vita=2, macos-widget=3) + platform: "pocketbook", + form: "takeover", + display: { + physicalViewport: [1024, 758], // Touch Lux 3 reference; host queries the panel + logicalViewports: [[480, 320]], // §9 — must be ≤511/axis for touch + presentations: ["integer-fit"], + rasterDensity: 2, + }, + capabilities: [ + "input.buttons", // D-pad/OK/Back/Menu/Home + page-turn keys + "input.touch", // capacitive single-touch + "text.glyphs.baked", // font atlases baked at build time + // NOT: input.analog.left (no stick), input.cursor (no synthesized pointer + // needed — real touch), display.viewport.live (orientation = restart) + ], +}, +``` + +Then register a `pocketbook` build backend in the build dispatch registry +(wherever `psp`/`vita` backends are selected — locate via the `POCKET_TARGETS` +consumers in `tools/`). The host's `set_identity("pocketbook", 4)` (§4) must +match `hostAbi` here, or plan-built bundles refuse the host +(`framework/src/host.ts::assertNativeHostContract`). + +--- + +## 11. Background color note + +`raster::render*` clears to **opaque black**. E-ink idles white, and most +Tailwind UIs are dark-on-light. Two clean options: + +- **App-side (preferred):** the app's root style paints a white background — + no host special-casing, and dark mode is just another style. +- **Host-side invert:** for a reader-style dark-mode toggle, invert in + `to_gray_and_diff` (`gray = 255 - gray`) and call `refresh.full()` on switch. + +--- + +## 12. Testing strategy + +| Layer | What | Command / how | +| --- | --- | --- | +| 1. Logic | App behavior, no device | `hosts/sim` scripted-input golden run (unchanged) | +| 2. Raster | Byte-exact RGBA8 of the DrawList | unit test: `raster::render_scaled` → write PNG, inspect | +| 3. Gray + damage | Luminance + tile diff correctness | unit tests in `framebuffer.rs` (synthetic fb → known dirty tiles) | +| 4. Desktop preview | Fast iteration of the same core+framework | `cargo run -p uihost -- --app ` (wgpu host) | +| 5. Host loop off-device | Event loop + refresh state machine | feed synthetic `Event`s into `input.rs`; assert `Refresh` picks partial vs dynamic vs full | +| 6. On-device | Touch accuracy, ghosting, latency, battery | deploy (§13), tune §9 + §7 thresholds | +| 7. Replay | Deterministic debug | record `(tick, buttons, touches)` JSON on device, replay in sim | + +The `pocket-mod` addition (§6) gets a unit test mirroring the existing +`frame_passes_analog_and_defaults_to_center`: eval a bundle that latches the +3rd arg, call `frame_with_touches`, assert the decoded contacts. + +--- + +## 13. Deployment + +```bash +# Build host +cd hosts/pocketbook +cargo zigbuild --release --target armv7-unknown-linux-gnueabi.2.23 + +# Build app (from repo root) +bun tools/build.ts --target pocketbook # emits dist/.js + dist/.pak + +# Copy to device (USB mass storage) +D=/mnt/ext1/applications/myapp +cp target/armv7-unknown-linux-gnueabi.2.23/release/pocketbook-host $D/myapp +cp dist/.js $D/app.js +cp dist/.pak $D/app.pak +chmod +x $D/myapp +# optional: $D/icon.bmp for the launcher +``` + +--- + +## 14. Phased checklist (revised against real APIs) + +- [ ] **Phase 0 — Refactor**: extract `pocket-ui-surface` (§2); confirm `uihost` + + OpenStrike still build. *(skip for a throwaway spike; land before merge)* +- [ ] **Phase 1 — Skeleton** (static screen on device) + - [ ] `hosts/pocketbook` crate + workspace membership + zigbuild target + - [ ] `main.rs`: channel loop, `Screen::new`, boot guest via `UiSurface` + - [ ] `framebuffer.rs`: `render_scaled` → Gray8, full blit each tick + - [ ] `refresh.rs`: `partial_update` only (no policy yet) + - [ ] Render a test app on device +- [ ] **Phase 2 — Input** + - [ ] `pocket-mod::frame_with_touches` (+ unit test) + - [ ] `input.rs`: keys→BTN, pointer→packed touch with §9 scaling/clamp + - [ ] Button + touch demo app on device +- [ ] **Phase 3 — Refresh policy** + - [ ] Port slint `is_updating`/dynamic/partial/200ms-cleanup strategy (§7) + - [ ] Tune 20 ms / 200 ms thresholds on device +- [ ] **Phase 4 — Polish** + - [ ] Dirty-tile merging; adaptive tick rate (30 fps active / ~5 fps idle) + - [ ] §9 per-device logical viewport config; orientation handling (`Event::Show` full redraw) + - [ ] Optional dithering for `gradRect` +- [ ] **Phase 5 — Upstream** + - [ ] `pocketbook` target profile + build backend (§10) + - [ ] sim golden + raster/gray/refresh unit tests (§12) + - [ ] `docs/` page; draft PR per `AGENTS.md` (Conventional Commits: + `feat(hosts): add PocketBook inkview host`) + +--- + +## 15. Open questions / risks + +1. **Logical viewport per device (§9)** — needs on-hardware tuning; the 9-bit + touch limit is the binding constraint. +2. **`pocket-ui-surface` extraction (§2)** — confirm `dbg.rs` (`DbgMailbox`) + has no wgpu coupling before moving (it probes a files/mailbox transport; + should be clean). +3. **inkview dependency source** — crates.io availability of `inkview 0.3`; + until confirmed, use a git/path dep. SDK feature (`sdk-6-10` default) must + match the target firmware. +4. **`Screen<'static>` threading** — the slint demo creates `Screen` on the + render thread after `Init`; confirm `GetTaskFramebuffer` is valid from that + thread on real firmware (it is on the emulator via the `GetCanvas` fallback + in `screen.rs`). +5. **`KeyRepeat` as button-held** — treated as "still down" here; verify the + framework's edge detection is happy with repeat events (it edge-detects on + the bitmask, so repeats are harmless). +6. **Battery vs tick rate** — 30 fps logical tick is a starting point; measure + and consider dropping to ~10 fps when `dirty` is empty for N consecutive + ticks. + +--- + +## Key references (verified paths) + +| File | Role | +| --- | --- | +| `engine/crates/pocket-ui-wgpu/src/surface.rs` | `UiSurface` — the `ui` HostOps surface to reuse | +| `engine/crates/pocket-ui-wgpu/src/pak.rs` | `walk_pak`/`find_pak` — pak format | +| `engine/pocket3d/examples/uihost/src/main.rs` | Canonical boot + tick loop + BTN bits | +| `engine/crates/pocket-mod/src/lib.rs` | `Guest` — add `frame_with_touches` here | +| `engine/core/src/lib.rs` | `Ui::tick`/`draw`/`set_viewport`/`new_with_raster_density` | +| `engine/core/src/raster.rs` | `render_scaled` (RGBA8, integer scale 1..4) | +| `engine/core/src/draw.rs` | `DrawList { pub words: Vec }` | +| `framework/src/touch.ts` | Touch packing `(id<<18) | (y<<9) | x`, 9-bit limit | +| `framework/src/host.ts` | `frame(buttons, analog?, touches?)`, host-contract assert | +| `contracts/spec/platforms.ts` | `POCKET_TARGETS` — add `pocketbook` (vita-shaped) | +| `inkview/src/{lib,screen,event}.rs` | `load`, `iv_main`, `Screen`, `Event`, `Key` | +| `inkview-slint/src/lib.rs` | Reference refresh policy + channel event loop | +| `inkview-rs/examples/inkview-slint-demo/src/main.rs` | `Box::leak` + channel + render-thread wiring | diff --git a/pocketjs-inkview-integration.md b/pocketjs-inkview-integration.md new file mode 100644 index 00000000..6c120093 --- /dev/null +++ b/pocketjs-inkview-integration.md @@ -0,0 +1,1398 @@ +# PocketJS × inkview: PocketBook E-Ink Backend Integration Guide + +> **Goal:** Create a new PocketJS host that renders the PocketJS UI engine on PocketBook +> e-readers via the inkview SDK, enabling native-speed applications built with +> Solid/Vue Vapor + Tailwind on e-ink hardware. + +--- + +## Table of Contents + +1. [Architecture Overview](#1-architecture-overview) +2. [Prerequisites](#2-prerequisites) +3. [Project Setup](#3-project-setup) +4. [Component 1 — QuickJS Hosting & HostOps](#4-component-1--quickjs-hosting--hostops) +5. [Component 2 — Framebuffer Pipeline](#5-component-2--framebuffer-pipeline) +6. [Component 3 — E-Ink Refresh Manager](#6-component-3--e-ink-refresh-manager) +7. [Component 4 — Input Mapping](#7-component-4--input-mapping) +8. [Component 5 — Main Loop](#8-component-5--main-loop) +9. [Target Profile Registration](#9-target-profile-registration) +10. [Pak Loading & Build Integration](#10-pak-loading--build-integration) +11. [Cross-Compilation & Deployment](#11-cross-compilation--deployment) +12. [Testing Strategy](#12-testing-strategy) +13. [Optimization & Polish](#13-optimization--polish) +14. [Key References](#14-key-references) + +--- + +## 1. Architecture Overview + +### The Full Pipeline + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ app.tsx (Solid or Vue Vapor + Tailwind classes) │ +│ Compiled by @pocketjs/framework compiler (jsx-plugin, pak) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ bundle.js + styles.bin + font atlases + images + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ QuickJS (single realm, ~ES2023) │ +│ framework/src/renderer-solid.ts or renderer-vue-vapor.ts │ +│ Emits ui.* ops via globalThis.ui (HostOps interface) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ ~17 numeric FFI calls (createNode, setProp, setText…) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ pocketjs-core (Rust, no_std) │ +│ UI tree · taffy flexbox layout · style resolve · animation │ +│ Ui::tick(dt=1/60) → Ui::draw() → DrawList (Vec) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ DrawList: 8 opcodes (rect, gradRect, glyphRun, + │ texQuad, scissor, scissorPop, tri, texTri) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Software Rasterizer (engine/core/src/raster.rs) │ +│ render(ui, words, fb) → RGBA8 framebuffer │ +│ Already exists — used by wasm/web/sim hosts │ +└──────────────────────────┬──────────────────────────────────────┘ + │ RGBA8 pixels (logical viewport resolution) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ NEW: hosts/pocketbook │ +│ │ +│ ┌──────────────┐ ┌──────────────────┐ ┌───────────────────┐ │ +│ │ Gray8 Convert │ │ Damage Tracker │ │ Refresh Manager │ │ +│ │ + Dithering │ │ (dirty regions) │ │ (partial/dynamic/ │ │ +│ │ │ │ │ │ full selection) │ │ +│ └──────┬───────┘ └────────┬─────────┘ └─────────┬─────────┘ │ +│ └───────────────────┼──────────────────────┘ │ +│ ▼ │ +│ inkview Screen blit │ +│ + PartialUpdate / DynamicUpdate / FullUpdate │ +└──────────────────────────┬──────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ libinkview.so → PocketBook e-ink display │ +│ (8-bit grayscale, 1024×758 to 1872×1404 depending on model) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Why This Works + +PocketJS's rendering is split into three strictly separated layers: + +1. **Core** (Rust, `no_std`): owns the UI tree, runs layout/animation, emits a DrawList. +2. **DrawList**: a flat `Vec` command stream — the core→backend IR. 8 opcodes, + format pinned in `contracts/spec/spec.ts`, codegen'd to `engine/core/src/spec.rs`. + All coordinates are pre-clipped to the viewport by the core's Sutherland–Hodgman + stage; backends do **zero** clipping. +3. **Backend**: walks the DrawList and renders it. Existing backends target sceGu (PSP), + vita2d/GXM (Vita), wgpu (desktop), and a software rasterizer (wasm/web/sim). + +The **software rasterizer** already produces an RGBA8 framebuffer from any DrawList. +For an e-ink device with no GPU, we reuse it directly and add only the +RGBA8→Gray8 conversion + e-ink refresh management on top. + +### What Changes Where + +| Project | Changes | +| --------- | --------- | +| **inkview-rs** | **None.** Used as a dependency as-is. | +| **pocketjs-core** | **None.** DrawList + software rasterizer used unchanged. | +| **pocketjs framework** | **None.** Solid/Vue Vapor renderers are host-agnostic. | +| **pocketjs contracts** | **Additive.** New target profile in `platforms.ts`, new build backend entry. | +| **NEW `hosts/pocketbook/`** | **All the new code.** ~1000–2000 lines of Rust. | + +--- + +## 2. Prerequisites + +### Knowledge + +- Basic Rust (structs, traits, closures — you don't need to be an expert) +- Familiarity with PocketJS app development (you'll write apps in Solid/Vue Vapor) +- Understanding of e-ink display characteristics (ghosting, refresh modes) + +### Tools + +| Tool | Purpose | +| ------ | --------- | +| Rust toolchain (stable) | Compile the host | +| `cargo-zigbuild` | Cross-compile for PocketBook's ARM Linux (glibc 2.23) | +| Zig | Required by cargo-zigbuild for cross-linking | +| Node.js + pnpm | Build the JS framework/bundles | +| A PocketBook device | On-device testing (any model running SDK 6.5+ firmware) | + +### Key Dependencies (Rust) + +```toml +[dependencies] +pocketjs-core = { path = "../../engine/core" } # UI engine + software rasterizer +inkview = "0.3" # PocketBook SDK bindings +pocket-mod = { path = "../../engine/crates/pocket-mod" } # QuickJS hosting (optional, see §4) +``` + +--- + +## 3. Project Setup + +### Directory Structure + +Following PocketJS's placement rules (`docs/STRUCTURE.md`), the new host lives at: + +``` +pocketjs/ +└── hosts/ + └── pocketbook/ + ├── Cargo.toml + ├── build.rs # Optional: embed pak at compile time + └── src/ + ├── main.rs # Entry point, event loop glue + ├── host_ops.rs # HostOps → pocketjs_core::Ui forwarders + ├── framebuffer.rs # RGBA8 → Gray8 conversion + dithering + ├── refresh.rs # E-ink refresh manager (damage tracking) + ├── input.rs # inkview events → PocketJS input state + └── ffi.rs # QuickJS globalThis.ui installation +``` + +### Cargo.toml + +```toml +[package] +name = "pocketbook-host" +version = "0.1.0" +edition = "2021" + +[dependencies] +pocketjs-core = { path = "../../engine/core" } +inkview = "0.3" +# Option A: use pocket-mod for QuickJS hosting (simpler) +pocket-mod = { path = "../../engine/crates/pocket-mod" } +# Option B: embed QuickJS directly like the PSP host (tighter control) +# libquickjs-sys = { path = "../../engine/crates/libquickjs-sys" } + +[profile.release] +opt-level = "s" # Size-optimized (e-ink devices have limited storage) +lto = true +strip = true +panic = "abort" +``` + +### Cross-Compilation Target + +PocketBook devices run ARM Linux with glibc 2.23: + +```bash +# Install once +rustup target add armv7-unknown-linux-gnueabi +cargo install cargo-zigbuild + +# Build +cargo zigbuild --release --target armv7-unknown-linux-gnueabi.2.23 + +# The binary dynamically loads libinkview.so at runtime — no SDK needed at build time +``` + +This is the same approach inkview-rs uses. The resulting binary is a standalone +ELF that you copy to the PocketBook's `applications/` directory. + +--- + +## 4. Component 1 — QuickJS Hosting & HostOps + +### What This Does + +Installs the `globalThis.ui` namespace inside QuickJS so the JS framework can +call `ui.createNode()`, `ui.setProp()`, etc. Each call forwards 1:1 to a +`pocketjs_core::Ui` method. + +### Approach A: Using `pocket-mod` (Recommended for Starting) + +The `pocket-mod` crate handles QuickJS realm lifecycle, surface mounting, and +per-tick pumping. The desktop wgpu host (`pocket-ui-wgpu/src/surface.rs`) uses it. + +```rust +// src/ffi.rs — sketch +use pocket_mod::Guest; +use pocketjs_core::Ui; + +pub fn install_ui_namespace(guest: &mut Guest, ui: &mut Ui) { + guest.mount("ui", |ctx, ns| { + // Identity markers — the framework checks these to know it's a native host + ns.set("__host", "pocketbook"); + ns.set("__hostAbi", 2); // Match current ABI version from contracts/spec/spec.ts + + // The 17 required HostOps — each is a thin forwarder: + ns.func("createNode", |ctx, args| { + let node_type = args[0].as_i32(); + let id = ui.create_node(node_type); + Ok(id.into()) + }); + + ns.func("destroyNode", |ctx, args| { + ui.destroy_node(args[0].as_i32()); + Ok(().into()) + }); + + ns.func("insertBefore", |ctx, args| { + ui.insert_before(args[0].as_i32(), args[1].as_i32(), args[2].as_i32()); + Ok(().into()) + }); + + ns.func("removeChild", |ctx, args| { + ui.remove_child(args[0].as_i32(), args[1].as_i32()); + Ok(().into()) + }); + + ns.func("setStyle", |ctx, args| { + ui.set_style(args[0].as_i32(), args[1].as_i32()); + Ok(().into()) + }); + + ns.func("setProp", |ctx, args| { + ui.set_prop(args[0].as_i32(), args[1].as_i32(), args[2].as_f64()); + Ok(().into()) + }); + + ns.func("setText", |ctx, args| { + let text = args[1].as_str(); + ui.set_text(args[0].as_i32(), text); + Ok(().into()) + }); + + ns.func("replaceText", |ctx, args| { + let text = args[1].as_str(); + ui.replace_text(args[0].as_i32(), text); + Ok(().into()) + }); + + ns.func("uploadTexture", |ctx, args| { + let buf = args[0].as_bytes(); + let (w, h, psm) = (args[1].as_i32(), args[2].as_i32(), args[3].as_i32()); + let handle = ui.upload_texture(buf, w, h, psm); + Ok(handle.into()) + }); + + ns.func("setImage", |ctx, args| { + ui.set_image(args[0].as_i32(), args[1].as_i32()); + Ok(().into()) + }); + + ns.func("setSprite", |ctx, args| { + ui.set_sprite(args[0].as_i32(), args[1].as_i32(), + args[2].as_i32(), args[3].as_i32(), args[4].as_f64()); + Ok(().into()) + }); + + ns.func("animate", |ctx, args| { + let id = ui.animate( + args[0].as_i32(), args[1].as_i32(), args[2].as_f64(), + args[3].as_f64(), args[4].as_i32(), args[5].as_f64(), + ); + Ok(id.into()) + }); + + ns.func("cancelAnim", |ctx, args| { + ui.cancel_anim(args[0].as_i32()); + Ok(().into()) + }); + + ns.func("setFocus", |ctx, args| { + ui.set_focus(args[0].as_i32()); + Ok(().into()) + }); + + ns.func("measureText", |ctx, args| { + let text = args[0].as_str(); + let font_slot = args[1].as_i32(); + let width = ui.measure_text(text, font_slot); + Ok(width.into()) + }); + + // Optional: fast batch path + ns.func("setPropBatch", |ctx, args| { + let records = args[0].as_bytes(); + ui.set_prop_batch(records); + Ok(().into()) + }); + }); +} +``` + +> **Note:** The exact `pocket-mod` API (`mount`, `ns.func`, argument accessors) +> is sketched from the patterns in `engine/crates/pocket-ui-wgpu/src/surface.rs` +> and `hosts/psp/src/ffi.rs`. Consult those files for the precise signatures. + +### Approach B: Direct QuickJS (Like the PSP Host) + +The PSP host (`hosts/psp/src/ffi.rs:821–946`) embeds QuickJS directly via +`libquickjs-sys`, using `JS_NewCFunction2` + `JS_SetPropertyStr` for each op. +This gives tighter control (arena allocator, VFPU worker thread on PSP) but is +more boilerplate. For PocketBook, Approach A is sufficient — the device has +ample CPU/RAM compared to a PSP. + +### Pak Feeding + +Before JS eval, feed the pak (styles, font atlases, images) to the core natively +and expose name→handle tables: + +```rust +// In main.rs, before guest.eval(bundle_js): +let pak = load_pak_from_disk("app.pak"); // or embed at compile time + +// Feed styles + font atlases to the core +ui.load_styles(&pak.styles_bin); +for atlas in &pak.font_atlases { + ui.load_font_atlas(atlas); +} + +// Feed images and build the __textures table +let mut textures = HashMap::new(); +for (name, img) in &pak.images { + let handle = ui.upload_texture(&img.pixels, img.w, img.h, img.psm); + textures.insert(name.clone(), handle); +} + +// Expose as ui.__textures so the framework can resolve +guest.mount("ui", |ctx, ns| { + ns.set("__textures", textures_to_js_object(ctx, &textures)); +}); +``` + +--- + +## 5. Component 2 — Framebuffer Pipeline + +### What This Does + +Takes the DrawList from `Ui::draw()`, rasterizes it to RGBA8 using the existing +software rasterizer, then converts to 8-bit grayscale with dithering for the +e-ink display. + +### Step 1: Software Raster (Already Exists) + +```rust +// src/framebuffer.rs — sketch +use pocketjs_core::{Ui, raster}; + +pub struct FramebufferPipeline { + rgba: Vec, // RGBA8 scratch buffer (logical viewport size) + gray: Vec, // Gray8 output buffer + prev_gray: Vec, // Previous frame (for damage detection) + width: usize, + height: usize, +} + +impl FramebufferPipeline { + pub fn new(width: usize, height: usize) -> Self { + let size = width * height; + Self { + rgba: vec![0u8; size * 4], + gray: vec![255u8; size], // Start white + prev_gray: vec![255u8; size], // Start white + width, + height, + } + } + + /// Rasterize the current DrawList into the RGBA8 buffer. + pub fn rasterize(&mut self, ui: &Ui, words: &[u32]) { + // The core's software rasterizer — same one used by wasm/sim hosts. + // Signature: render(ui, words, fb) where fb is &mut [u8] of size w*h*4. + // Clears to transparent, then draws all ops. + raster::render(ui, words, &mut self.rgba); + } +``` + +### Step 2: RGBA8 → Gray8 Conversion with Dithering + +```rust + /// Convert RGBA8 → Gray8 with Floyd–Steinberg dithering. + /// Returns a list of dirty (x, y, w, h) regions that changed. + pub fn convert_and_diff(&mut self) -> Vec { + let (w, h) = (self.width, self.height); + + // --- RGBA8 → Gray8 (luminance) --- + // Use the same coefficients as inkview-rs: 0.2125R + 0.7154G + 0.0721B + // Also apply alpha compositing over white background. + for i in 0..w * h { + let r = self.rgba[i * 4 + 0] as f32; + let g = self.rgba[i * 4 + 1] as f32; + let b = self.rgba[i * 4 + 2] as f32; + let a = self.rgba[i * 4 + 3] as f32 / 255.0; + + let lum = 0.2125 * r + 0.7154 * g + 0.0721 * b; + // Composite over white (e-ink default background) + let composited = lum * a + 255.0 * (1.0 - a); + self.gray[i] = composited.clamp(0.0, 255.0) as u8; + } + + // --- Floyd–Steinberg dithering (optional, for gradients) --- + // Only apply if the UI uses gradients (gradRect ops). + // For flat-color UIs (most Tailwind-style apps), skip this — + // the 256 gray levels are more than enough. + // + // If needed, apply in-place on self.gray: + // self.floyd_steinberg_dither(); + + // --- Damage detection --- + let dirty = self.compute_dirty_regions(); + + // Swap for next frame + std::mem::swap(&mut self.gray, &mut self.prev_gray); + + dirty + } + + /// Find bounding boxes of changed pixel regions. + /// Uses a simple tile-based approach: divide the screen into NxN tiles, + /// mark tiles that have any changed pixel, merge adjacent dirty tiles. + fn compute_dirty_regions(&self) -> Vec { + const TILE: usize = 16; // 16×16 pixel tiles + let (w, h) = (self.width, self.height); + let tiles_x = (w + TILE - 1) / TILE; + let tiles_y = (h + TILE - 1) / TILE; + let mut dirty_tiles = vec![false; tiles_x * tiles_y]; + + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if self.gray[i] != self.prev_gray[i] { + dirty_tiles[(y / TILE) * tiles_x + (x / TILE)] = true; + } + } + } + + // Merge adjacent dirty tiles into rectangles + // (simple approach: one DirtyRect per dirty tile; optimize later) + dirty_tiles.iter().enumerate() + .filter(|(_, &dirty)| dirty) + .map(|(i, _)| { + let tx = (i % tiles_x) * TILE; + let ty = (i / tiles_x) * TILE; + DirtyRect { + x: tx, + y: ty, + w: TILE.min(w - tx), + h: TILE.min(h - ty), + } + }) + .collect() + } + + /// Blit the Gray8 buffer to the inkview screen. + pub fn blit_to_screen(&self, screen: &mut inkview::screen::Screen) { + let (w, h) = (self.width, self.height); + for y in 0..h { + for x in 0..w { + let gray = self.gray[y * w + x]; + screen.draw(x, y, inkview::screen::BB8(gray)); + } + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct DirtyRect { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} +``` + +### Optimization: Only Blit Dirty Regions + +Instead of blitting the entire framebuffer every frame, only write changed tiles: + +```rust + pub fn blit_dirty(&self, screen: &mut inkview::screen::Screen, dirty: &[DirtyRect]) { + for rect in dirty { + for y in rect.y..(rect.y + rect.h) { + for x in rect.x..(rect.x + rect.w) { + let gray = self.gray[y * self.width + x]; + screen.draw(x, y, inkview::screen::BB8(gray)); + } + } + } + } +``` + +### Optimization: Direct Gray8 Rasterization + +The software rasterizer currently outputs RGBA8. For a future optimization, you +could add a `render_gray8()` variant to `engine/core/src/raster.rs` that outputs +Gray8 directly, skipping the conversion pass. This is optional — the conversion +is cheap compared to the rasterization itself. + +--- + +## 6. Component 3 — E-Ink Refresh Manager + +### The Core Problem + +PocketJS ticks at 60fps. E-ink displays can't refresh that fast: + +| Update Mode | Latency | Quality | Flash? | +| ------------- | --------- | --------- | -------- | +| `FullUpdate` | 500ms–1s | Perfect | Yes (visible) | +| `PartialUpdate` | 100–300ms | High | No | +| `DynamicUpdate` | 50–100ms | Low (ghosting) | No | +| `SoftUpdate` | ~100ms | Medium | No | + +### Strategy: Decouple Logical Ticks from Physical Refreshes + +The UI core keeps ticking at 60fps (animations, input responsiveness). The +display only updates when content actually changes, using the fastest +acceptable refresh mode. + +```rust +// src/refresh.rs — sketch +use std::time::Instant; +use crate::framebuffer::DirtyRect; + +pub struct RefreshManager { + /// Accumulated damage since last full/partial update + accumulated_damage: Option, + /// Time of last screen update + last_update: Instant, + /// Time of last full (flashing) update + last_full_update: Instant, + /// Count of fast updates since last full update + fast_update_count: u32, + /// Whether a screen update is currently in progress + update_in_progress: bool, + /// Threshold: do a full update after this many fast updates + full_update_interval: u32, + /// Quiet period before final cleanup update (ms) + quiet_period_ms: u128, + /// Minimum interval between dynamic updates (ms) + min_dynamic_interval_ms: u128, +} + +impl RefreshManager { + pub fn new() -> Self { + Self { + accumulated_damage: None, + last_update: Instant::now(), + last_full_update: Instant::now(), + fast_update_count: 0, + update_in_progress: false, + full_update_interval: 20, // Full flash every 20 fast updates + quiet_period_ms: 200, // 200ms quiet → cleanup update + min_dynamic_interval_ms: 20, // Max ~50fps dynamic updates + } + } + + /// Called every tick with the dirty regions from the framebuffer pipeline. + /// Decides whether and how to update the physical display. + pub fn on_frame( + &mut self, + screen: &mut inkview::screen::Screen, + dirty: &[DirtyRect], + is_transition: bool, // true on screen/page changes + ) { + if dirty.is_empty() && !is_transition { + // No visual change — check if we need a delayed cleanup update + self.maybe_cleanup_update(screen); + return; + } + + // Merge all dirty rects into one bounding box + let merged = merge_rects(dirty); + self.accumulated_damage = Some(match self.accumulated_damage { + Some(existing) => union_rect(existing, merged), + None => merged, + }); + + let now = Instant::now(); + let elapsed = now.duration_since(self.last_update).as_millis(); + + if is_transition { + // Screen/page transition → full update (clean, no ghosting) + self.do_full_update(screen); + return; + } + + if self.update_in_progress { + // Previous update still running → use dynamic (fast/ugly) if enough time passed + if elapsed >= self.min_dynamic_interval_ms { + self.do_dynamic_update(screen, merged); + } + // Otherwise skip this frame's display update entirely + return; + } + + // Normal case: partial update on the damage region + self.do_partial_update(screen, merged); + } + + fn do_full_update(&mut self, screen: &mut inkview::screen::Screen) { + screen.full_update(); + self.last_full_update = Instant::now(); + self.last_update = Instant::now(); + self.fast_update_count = 0; + self.accumulated_damage = None; + self.update_in_progress = false; + } + + fn do_partial_update(&mut self, screen: &mut inkview::screen::Screen, rect: DirtyRect) { + screen.partial_update(rect.x, rect.y, rect.w, rect.h); + self.last_update = Instant::now(); + self.fast_update_count += 1; + self.accumulated_damage = None; + self.update_in_progress = true; // Will be cleared when is_updating() returns false + + // After N fast updates, schedule a full update to clear ghosting + if self.fast_update_count >= self.full_update_interval { + // Don't do it now (would flash during interaction) — + // it will trigger on the next quiet period + } + } + + fn do_dynamic_update(&mut self, screen: &mut inkview::screen::Screen, rect: DirtyRect) { + screen.dynamic_update(rect.x, rect.y, rect.w, rect.h); + self.last_update = Instant::now(); + self.fast_update_count += 1; + } + + /// After a quiet period, do a final high-quality update on accumulated damage. + /// Also triggers periodic full updates to prevent ghosting buildup. + fn maybe_cleanup_update(&mut self, screen: &mut inkview::screen::Screen) { + let now = Instant::now(); + let quiet_ms = now.duration_since(self.last_update).as_millis(); + + if quiet_ms < self.quiet_period_ms { + return; // Still in active interaction + } + + if let Some(damage) = self.accumulated_damage.take() { + // Final cleanup: high-quality partial update on accumulated region + screen.partial_update(damage.x, damage.y, damage.w, damage.h); + self.last_update = now; + self.update_in_progress = false; + } + + // Periodic full update to eliminate ghosting + let since_full = now.duration_since(self.last_full_update).as_millis(); + if self.fast_update_count >= self.full_update_interval && since_full > 2000 { + self.do_full_update(screen); + } + } + + /// Poll inkview's update status to clear the in_progress flag. + pub fn poll_update_status(&mut self, screen: &mut inkview::screen::Screen) { + if self.update_in_progress && !screen.is_updating() { + self.update_in_progress = false; + } + } +} + +fn merge_rects(rects: &[DirtyRect]) -> DirtyRect { + let mut x0 = usize::MAX; + let mut y0 = usize::MAX; + let mut x1 = 0usize; + let mut y1 = 0usize; + for r in rects { + x0 = x0.min(r.x); + y0 = y0.min(r.y); + x1 = x1.max(r.x + r.w); + y1 = y1.max(r.y + r.h); + } + DirtyRect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } +} + +fn union_rect(a: DirtyRect, b: DirtyRect) -> DirtyRect { + let x0 = a.x.min(b.x); + let y0 = a.y.min(b.y); + let x1 = (a.x + a.w).max(b.x + b.w); + let y1 = (a.y + a.h).max(b.y + b.h); + DirtyRect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } +} +``` + +### Tuning Guidelines + +| Scenario | Recommended Mode | Why | +| ---------- | ----------------- | ----- | +| Button press highlight | `PartialUpdate` on button rect | Small region, no flash | +| Text input / cursor blink | `PartialUpdate` on cursor area | Tiny region, frequent | +| List scrolling | `DynamicUpdate` during drag, `PartialUpdate` on release | Fast during motion, clean up after | +| Page/screen transition | `FullUpdate` | Complete redraw, eliminates ghosting from previous screen | +| Animation (e.g., progress bar) | `DynamicUpdate` at 20ms intervals, `PartialUpdate` when done | Smooth-ish motion, cleanup after | +| Idle / no changes | No update | Save power, e-ink holds image without refresh | + +--- + +## 7. Component 4 — Input Mapping + +### What This Does + +Translates inkview's touch and key events into PocketJS's `frame(buttons, analog?, touches?)` +call signature. + +### PocketJS Input Contract + +The host calls `globalThis.frame(buttons, analog?, touches?)` exactly once per tick: + +- `buttons`: a bitmask of currently-held buttons (spec-defined bit positions) +- `analog`: optional `{x, y}` for analog sticks (not applicable on PocketBook) +- `touches`: optional array of `{id, x, y, phase}` for touch points + +### Implementation + +```rust +// src/input.rs — sketch +use inkview::event::{Event, Key}; + +pub struct InputState { + /// Currently held buttons (bitmask) + pub buttons: u32, + /// Current touch point (PocketBook is single-touch) + pub touch: Option, + /// Events queued during this tick (processed at frame boundary) + pending_events: Vec, +} + +#[derive(Clone, Copy)] +pub struct TouchPoint { + pub x: i32, + pub y: i32, + pub phase: TouchPhase, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum TouchPhase { + Down, + Move, + Up, +} + +impl InputState { + pub fn new() -> Self { + Self { buttons: 0, touch: None, pending_events: Vec::new() } + } + + /// Called from the inkview event handler (may fire multiple times per tick) + pub fn push_event(&mut self, event: Event) { + self.pending_events.push(event); + } + + /// Called once per tick, before frame(). Processes all pending events. + pub fn drain(&mut self) { + for event in self.pending_events.drain(..) { + match event { + // --- Touch --- + Event::PointerDown { x, y } => { + self.touch = Some(TouchPoint { x, y, phase: TouchPhase::Down }); + } + Event::PointerMove { x, y } => { + self.touch = Some(TouchPoint { x, y, phase: TouchPhase::Move }); + } + Event::PointerUp { x, y } => { + self.touch = Some(TouchPoint { x, y, phase: TouchPhase::Up }); + } + + // --- Hardware keys → button bitmask --- + // Bit positions must match contracts/spec/spec.ts BUTTON enum. + Event::KeyDown { key } => self.buttons |= key_to_button_bit(key), + Event::KeyUp { key } => self.buttons &= !key_to_button_bit(key), + + _ => {} // Ignore Init, Show, Repaint, etc. + } + } + } +} + +/// Map PocketBook hardware keys to PocketJS button bits. +/// Consult contracts/spec/spec.ts for the canonical BUTTON bitmask values. +fn key_to_button_bit(key: Key) -> u32 { + match key { + Key::Up => 1 << 0, // BUTTON_UP + Key::Down => 1 << 1, // BUTTON_DOWN + Key::Left => 1 << 2, // BUTTON_LEFT + Key::Right => 1 << 3, // BUTTON_RIGHT + Key::Ok => 1 << 4, // BUTTON_CROSS / confirm + Key::Back => 1 << 5, // BUTTON_CIRCLE / back + Key::Menu => 1 << 6, // BUTTON_START / menu + Key::Prev => 1 << 7, // BUTTON_LTRIGGER / page prev + Key::Next => 1 << 8, // BUTTON_RTRIGGER / page next + Key::Prev2 => 1 << 7, // Alternate page-turn buttons → same as Prev + Key::Next2 => 1 << 8, // Alternate page-turn buttons → same as Next + Key::Home => 1 << 9, // BUTTON_SELECT / home + _ => 0, + } +} +``` + +### Coordinate Mapping + +Touch coordinates from inkview are in physical screen pixels. If the logical +viewport differs from the physical resolution (e.g., logical 480×272 on a +1024×758 screen with rasterDensity 2), scale accordingly: + +```rust +fn scale_touch(x: i32, y: i32, physical_w: usize, logical_w: usize) -> (i32, i32) { + let scale = logical_w as f32 / physical_w as f32; + ((x as f32 * scale) as i32, (y as f32 * scale) as i32) +} +``` + +--- + +## 8. Component 5 — Main Loop + +### The Event Loop Challenge + +inkview uses a **blocking callback-based** event loop (`iv_main`). PocketJS hosts +typically own their own tick loop. We need to reconcile these. + +### Solution: Timer-Driven Ticks Inside the inkview Event Loop + +Use inkview's `SetHardTimer` to drive ticks at 60fps (or a lower rate like 30fps +to save CPU — e-ink doesn't need 60fps display updates, but the core benefits +from consistent tick timing for animations). + +```rust +// src/main.rs — sketch +use std::sync::{Arc, Mutex}; +use inkview::{load, iv_main, screen::Screen}; +use pocketjs_core::Ui; + +// Shared state accessible from both the event handler and the timer callback. +// inkview's event handler and timer callbacks run on the same thread, +// so a simple Rc> would also work. Using Arc for safety. +struct AppState { + ui: Ui, + pipeline: FramebufferPipeline, + refresh: RefreshManager, + input: InputState, + screen: Screen<'static>, // 'static lifetime via unsafe; inkview is single-threaded + guest: Guest, // QuickJS realm + running: bool, +} + +fn main() { + // 1. Load inkview bindings + let iv = load().expect("Failed to load libinkview.so"); + + // 2. Query display properties + let phys_w = iv.ScreenWidth() as usize; + let phys_h = iv.ScreenHeight() as usize; + let dpi = iv.get_screen_dpi(); + let scale = iv.get_screen_scale_factor(); + + // 3. Choose logical viewport + // Option A: logical = physical (1:1, simplest) + // Option B: logical = physical/2 with rasterDensity 2 (like Vita's 480×272 on 960×544) + // For e-ink, 1:1 is usually best — you want all the pixels. + let logical_w = phys_w; + let logical_h = phys_h; + + // 4. Initialize the UI core + let mut ui = Ui::new(); + ui.set_viewport(logical_w as f32, logical_h as f32); + + // 5. Initialize QuickJS and install HostOps + let mut guest = Guest::new(); + install_ui_namespace(&mut guest, &mut ui); + + // 6. Load the pak and feed it natively + let pak = load_pak("app.pak"); + ui.load_styles(&pak.styles); + for atlas in &pak.font_atlases { + ui.load_font_atlas(atlas); + } + // ... upload textures, set __textures/__sprites ... + + // 7. Evaluate the JS bundle + guest.eval(&pak.bundle_js); + + // 8. Initialize framebuffer pipeline and refresh manager + let pipeline = FramebufferPipeline::new(logical_w, logical_h); + let refresh = RefreshManager::new(); + let input = InputState::new(); + let screen = Screen::new(&iv); + + // 9. Pack into shared state + let state = Arc::new(Mutex::new(AppState { + ui, pipeline, refresh, input, screen, guest, running: true, + })); + + // 10. Set up the tick timer (~60fps = every 16ms, or 30fps = every 33ms) + let tick_state = Arc::clone(&state); + iv.SetHardTimer(1, 16, move || { + tick(tick_state.clone()); + }); + + // 11. Enter the inkview event loop (blocks until app exit) + let event_state = Arc::clone(&state); + iv_main(&iv, move |event| { + let mut state = event_state.lock().unwrap(); + match event { + Event::Init => { + // App initialization complete + Some(()) + } + Event::Exit => { + state.running = false; + None // Let inkview handle exit + } + Event::Hide => { + // App moved to background — could pause the timer + Some(()) + } + Event::Show => { + // App returned to foreground — resume, do a full update + state.refresh.do_full_update(&mut state.screen); + Some(()) + } + // Forward input events to the input state + Event::KeyDown { .. } | Event::KeyUp { .. } + | Event::PointerDown { .. } | Event::PointerMove { .. } + | Event::PointerUp { .. } => { + state.input.push_event(event); + Some(()) + } + _ => None, + } + }); +} + +/// One tick of the PocketJS frame loop. +/// Called by the hardware timer at ~60fps. +fn tick(state: Arc>) { + let mut s = state.lock().unwrap(); + if !s.running { return; } + + // 1. Process input events accumulated since last tick + s.input.drain(); + + // 2. Call the JS frame handler: globalThis.frame(buttons, analog, touches) + // This is Law 3: one guest turn per host tick. + let buttons = s.input.buttons; + let touches = s.input.touch.map(|t| (t.x, t.y, t.phase)); + s.guest.call_frame(buttons, None, touches); + + // 3. Drain QuickJS microtask jobs + s.guest.drain_jobs(); + + // 4. Tick the UI core (fixed dt = 1/60) + s.ui.tick(1.0 / 60.0); + + // 5. Draw → DrawList + let words = s.ui.draw(); + + // 6. Rasterize DrawList → RGBA8 + s.pipeline.rasterize(&s.ui, &words); + + // 7. Convert RGBA8 → Gray8 + detect damage + let dirty = s.pipeline.convert_and_diff(); + + // 8. Blit dirty regions to the inkview framebuffer + if !dirty.is_empty() { + s.pipeline.blit_dirty(&mut s.screen, &dirty); + } + + // 9. Let the refresh manager decide how to update the physical display + s.refresh.poll_update_status(&mut s.screen); + s.refresh.on_frame(&mut s.screen, &dirty, false); +} +``` + +### Alternative: Lower Tick Rate for Battery + +E-ink doesn't need 60fps. You could tick at 30fps or even 20fps and still feel +responsive, while significantly reducing CPU usage and battery drain: + +```rust +// 30fps tick (33ms interval) — good balance for e-ink +iv.SetHardTimer(1, 33, move || { tick(state.clone()); }); + +// Or even adaptive: 60fps during touch interaction, 10fps when idle +``` + +--- + +## 9. Target Profile Registration + +Add a `pocketbook` target to `contracts/spec/platforms.ts`: + +```typescript +// contracts/spec/platforms.ts — add to the target registry + +pocketbook: { + hostAbi: 2, // Match current ABI version + platform: "pocketbook", + form: "takeover", // Fullscreen app (no windowing on PocketBook) + + display: { + // Physical resolution varies by device; these are common values. + // The host queries ScreenWidth()/ScreenHeight() at runtime. + physicalViewport: [1024, 758], // Touch Lux 3 (most common) + // physicalViewport: [1872, 1404], // InkPad 3 Pro + logicalViewports: [[1024, 758]], // 1:1 mapping (no scaling) + presentations: ["integer-fit"], + rasterDensity: 1, // 1:1 logical→physical + }, + + capabilities: [ + "input.buttons", // D-pad + OK/Back/Menu/Home + page-turn keys + "input.touch", // Capacitive touchscreen (single-touch) + "text.glyphs.baked", // Font atlases baked at build time + // NOT included: + // "input.analog.left" — no analog stick + // "input.cursor" — no mouse cursor + // "input.ime" — no hardware keyboard (could add via OpenKeyboard) + // "display.viewport.live" — orientation changes require app restart + ], +}, +``` + +And add a build backend entry in the dispatch registry: + +```typescript +// In the build system's target backend registry: +const targetBackends = { + psp: pspBackend, + vita: vitaBackend, + pocketbook: pocketbookBackend, // NEW +} satisfies Record; +``` + +--- + +## 10. Pak Loading & Build Integration + +### Build Flow + +PocketJS apps are compiled into a **pak** — a bundle containing: + +| File | Contents | +| ------ | ---------- | +| `bundle.js` | Compiled app (Solid/Vue Vapor → universal renderer) | +| `styles.bin` | Tailwind classes → style table records | +| `font-atlas-*.bin` | Baked Inter glyph atlases (exactly the app's codepoints) | +| `images/` | PNG/JPEG textures referenced by the app | +| `sprites/` | Sprite sheet definitions | +| `pocket.json` | App manifest (name, entry, target, framework) | + +### On the Host Side + +The PocketBook host loads the pak at startup: + +```rust +/// Load a pak directory from the PocketBook's filesystem. +/// Apps are typically stored in /mnt/ext1/applications// +fn load_pak(path: &str) -> Pak { + let bundle_js = std::fs::read_to_string(format!("{}/bundle.js", path)).unwrap(); + let styles = std::fs::read(format!("{}/styles.bin", path)).unwrap(); + + let mut font_atlases = Vec::new(); + for entry in std::fs::read_dir(path).unwrap() { + let name = entry.unwrap().file_name().to_string_lossy().to_string(); + if name.starts_with("font-atlas-") && name.ends_with(".bin") { + font_atlases.push(std::fs::read(entry.unwrap().path()).unwrap()); + } + } + + let mut images = HashMap::new(); + let img_dir = format!("{}/images", path); + if std::path::Path::new(&img_dir).exists() { + for entry in std::fs::read_dir(&img_dir).unwrap() { + let entry = entry.unwrap(); + let name = entry.file_name().to_string_lossy().to_string(); + let data = std::fs::read(entry.path()).unwrap(); + // Decode PNG/JPEG → raw RGBA pixels + let img = decode_image(&data); + images.insert(name, img); + } + } + + Pak { bundle_js, styles, font_atlases, images } +} +``` + +### Using the Framework's Build API + +Custom hosts consume build plans through the stable boundary: + +```typescript +// In the build script (TypeScript side) +import { extractHostBuildInputs, hostBuildEnvironment } from "@pocketjs/framework/manifest"; + +const inputs = extractHostBuildInputs(planJson, { expectedTarget: "pocketbook" }); +const env = hostBuildEnvironment(inputs, { + outputDirectory: "dist/pocket/pocketbook", + embedApp: false, // Load from filesystem at runtime +}); +``` + +--- + +## 11. Cross-Compilation & Deployment + +### Build Commands + +```bash +# One-time setup +rustup target add armv7-unknown-linux-gnueabi +cargo install cargo-zigbuild + +# Build the host binary +cd hosts/pocketbook +cargo zigbuild --release --target armv7-unknown-linux-gnueabi.2.23 + +# Build the app bundle (from the app directory) +cd ../../apps/my-app +npx pocketjs build --target pocketbook +``` + +### Deployment to Device + +```bash +# Connect PocketBook via USB (appears as mass storage) +# Copy the binary: +cp target/armv7-unknown-linux-gnueabi.2.23/release/pocketbook-host \ + /mnt/ext1/applications/myapp/myapp + +# Copy the pak: +cp -r dist/pocket/pocketbook/* /mnt/ext1/applications/myapp/ + +# The binary must be executable: +chmod +x /mnt/ext1/applications/myapp/myapp +``` + +### PocketBook App Registration + +PocketBook discovers apps in `/mnt/ext1/applications/`. Each app directory +needs the binary and optionally an icon: + +``` +/mnt/ext1/applications/myapp/ +├── myapp # The ELF binary (renamed to app name) +├── bundle.js # JS bundle +├── styles.bin # Compiled styles +├── font-atlas-0.bin # Font atlases +├── images/ # Textures +└── icon.bmp # App icon (optional, shown in launcher) +``` + +### inkview's Dynamic Loading + +The `inkview` crate loads `libinkview.so` at runtime via `libloading`. This +means: + +- **No SDK installation needed** on the build machine +- **No static linking** to PocketBook's C libraries +- The binary works across firmware versions (SDK 5.19–6.10, selectable via + cargo features) +- If `libinkview.so` is missing, the app fails gracefully with an error message + +--- + +## 12. Testing Strategy + +### Layer 1: Headless Simulation (No Device Needed) + +PocketJS has a deterministic sim host (`hosts/sim/`) that runs the same wasm core +with scripted inputs and produces per-frame framebuffer hashes. Use it to verify +your app's logic is correct before touching the device: + +```bash +# Run the sim host against your app +cd hosts/sim +node sim.ts --app ../../apps/my-app --frames 300 --input scripted-input.json +# Produces golden hashes — byte-identical across runs +``` + +### Layer 2: Software Raster Golden Tests + +The software rasterizer produces byte-exact RGBA8 output. You can render your +app's frames on your dev machine and visually inspect them: + +```rust +// In a test or example: +let mut fb = vec![0u8; 1024 * 758 * 4]; +raster::render(&ui, &words, &mut fb); +// Write fb as a PNG for inspection +write_png("frame_001.png", 1024, 758, &fb); +``` + +### Layer 3: Desktop Preview with wgpu Host + +Run your app on the desktop wgpu host for fast iteration: + +```bash +cd engine/crates/pocket-ui-wgpu +cargo run --example uihost -- --app ../../../apps/my-app +``` + +This uses the same core + framework, just with a GPU backend instead of e-ink. + +### Layer 4: On-Device Testing + +For final validation, deploy to the PocketBook and test: + +- Touch responsiveness and accuracy +- Refresh behavior (ghosting, flashing, update latency) +- Battery impact of tick rate +- Different screen sizes/orientations +- Edge cases: low battery, incoming notifications, app switching + +### Layer 5: Deterministic Replay + +Because PocketJS's frame model is deterministic (`frame(tick, inputs) → pixels`), +you can record input sequences on-device and replay them in the sim host for +debugging: + +```json +// recorded-input.json +{ + "frames": [ + { "tick": 0, "buttons": 0, "touches": [] }, + { "tick": 60, "buttons": 0, "touches": [{"x": 512, "y": 400, "phase": "down"}] }, + { "tick": 65, "buttons": 0, "touches": [{"x": 512, "y": 400, "phase": "up"}] } + ] +} +``` + +--- + +## 13. Optimization & Polish + +### Performance + +| Optimization | Impact | Effort | +| ------------- | -------- | -------- | +| **Dirty-region rasterization** — only re-rasterize tiles that changed | High (avoids full-frame raster on small changes) | Medium | +| **Adaptive tick rate** — 60fps during touch, 10fps idle | High (battery life) | Low | +| **Direct Gray8 rasterizer** — skip RGBA8 intermediate | Medium (saves one full-frame pass) | Medium | +| **ARM NEON for RGBA→Gray conversion** | Low (conversion is already fast) | Low | +| **Pre-quantized color palette** — restrict UI to 16 grays at design time | Medium (faster dithering, cleaner e-ink output) | Low (design constraint) | + +### E-Ink Quality + +- **Ghosting management:** The refresh manager's periodic full-update is critical. + Tune `full_update_interval` based on your app's UI density. Text-heavy apps + (readers) can go longer; graphically dynamic apps need more frequent full updates. + +- **Dithering strategy:** For flat-color Tailwind UIs, no dithering is needed — + 256 gray levels handle it. Enable Floyd–Steinberg only for `gradRect` ops + (gradients). Ordered (Bayer) dithering is faster and produces less visual noise + for UI elements. + +- **Dark mode:** E-ink displays look best with dark-on-light. If supporting dark + mode, invert the Gray8 buffer before blitting (`gray = 255 - gray`) and use + `FullUpdate` on mode switch. + +- **Text rendering:** PocketJS bakes font atlases at build time. Ensure the + rasterDensity matches the device DPI for crisp text. At 1:1 density on a + 212 DPI screen, baked Inter at the right size will look sharp. + +### PocketBook-Specific Features (Future) + +These are inkview capabilities not in the base PocketJS contract that could be +exposed as optional host ops: + +| Feature | inkview API | PocketJS Integration | +| --------- | ------------ | --------------------- | +| Hardware keyboard | `OpenKeyboard()` | Optional `input.ime` capability | +| Battery status | `BatteryPower()`, `IsCharging()` | Expose as `ui.battery()` | +| WiFi | `ConnectNet()`, `QueryNetwork()` | Expose as `ui.network()` | +| File browser | `OpenDirectorySelector()` | Expose as `ui.pickFile()` | +| Front light | (device-specific ioctl) | Expose as `ui.setBrightness()` | +| Orientation | `SetOrientation()`, g-sensor | Expose as `ui.setOrientation()` | +| Config/INI | `OpenConfig()`, `SaveConfig()` | Expose as `ui.settings()` | + +These would be **optional capability-gated ops** — the framework already +feature-detects optional HostOps methods. + +--- + +## 14. Key References + +### PocketJS Source Files + +| File | What It Tells You | +| ------ | ------------------- | +| `docs/DESIGN.md` | Overall architecture philosophy | +| `docs/RUNTIMES.md` | The three laws, Runtime = ⟨Cores, Surfaces, Guest⟩ | +| `docs/STRUCTURE.md` | Where new code goes, placement rules | +| `docs/PLATFORM.md` | Platform contracts, capability registry | +| `contracts/spec/spec.ts` | THE spec: op codes, prop IDs, DrawList format, enums | +| `contracts/spec/platforms.ts` | Target profiles (add `pocketbook` here) | +| `engine/core/src/lib.rs` | `Ui` struct, `tick()`, `draw()`, `set_viewport()` | +| `engine/core/src/draw.rs` | DrawList format, DRAW_OP enum | +| `engine/core/src/raster.rs` | Software rasterizer (`render`, `render_scaled`) | +| `framework/src/host.ts` | `HostOps` interface (the 17 required + optional ops) | +| `framework/src/native-tree.ts` | NodeMirror arena (JS-side tree mirror) | +| `framework/src/renderer-solid.ts` | Solid universal renderer over HostOps | +| `framework/src/renderer-vue-vapor.ts` | Vue Vapor renderer over HostOps | +| `hosts/psp/src/ffi.rs` | PSP's HostOps installation (reference implementation) | +| `hosts/psp/src/ge.rs` | PSP's DrawList walker (reference for a GPU backend) | +| `hosts/psp/src/main.rs` | PSP's frame loop ordering | +| `engine/crates/pocket-mod/src/lib.rs` | QuickJS hosting library | +| `engine/crates/pocket-ui-wgpu/src/surface.rs` | Desktop host using pocket-mod | +| `site/content/docs/platform-contracts.md` | Custom host build API | + +### inkview-rs Source Files + +| File | What It Tells You | +| ------ | ------------------- | +| `inkview/src/lib.rs` | `load()`, `iv_main()`, dynamic loading | +| `inkview/src/screen.rs` | `Screen` struct, draw/update methods, pixel formats | +| `inkview/src/event.rs` | `Event` enum, `Key` enum | +| `inkview/src/bindings.rs` | Raw C API (generated by bindgen) | +| `inkview-eg/src/lib.rs` | embedded-graphics DrawTarget (flush strategy reference) | +| `inkview-slint/src/lib.rs` | Slint backend (refresh strategy reference!) | + +### External References + +| Resource | URL | +| ---------- | ----- | +| inkview-rs | | +| pocketjs | | +| PocketBook SDK header | | +| pb-cheatsheet (real-world inkview usage) | | +| inkview Go SDK (additional API docs) | | + +--- + +## Appendix: Implementation Checklist + +- [ ] **Phase 1 — Skeleton** (MVP: static screen renders on device) + - [ ] Create `hosts/pocketbook/` crate with Cargo.toml + - [ ] Implement `main.rs`: load inkview, create Screen, enter event loop + - [ ] Implement `host_ops.rs`: 17 HostOps forwarders to `Ui` + - [ ] Implement `ffi.rs`: install `globalThis.ui` in QuickJS via pocket-mod + - [ ] Implement `framebuffer.rs`: software raster → Gray8 (no dithering yet) + - [ ] Blit full framebuffer on every tick with `SoftUpdate` + - [ ] Load a simple test app pak, verify it renders on device + +- [ ] **Phase 2 — Input** (touch + keys work) + - [ ] Implement `input.rs`: event → button/touch mapping + - [ ] Wire into `frame(buttons, touches)` call + - [ ] Test with a button-press demo app + +- [ ] **Phase 3 — Refresh Management** (usable e-ink experience) + - [ ] Implement `refresh.rs`: damage tracking + update mode selection + - [ ] Partial updates for small changes + - [ ] Dynamic updates during scroll/drag + - [ ] Full updates on transitions + periodic ghosting cleanup + - [ ] Tune thresholds on device + +- [ ] **Phase 4 — Polish** + - [ ] Floyd–Steinberg / ordered dithering for gradients + - [ ] Adaptive tick rate (60fps active, 10fps idle) + - [ ] Dirty-region-only blitting + - [ ] Multiple device resolution support + - [ ] Orientation handling + +- [ ] **Phase 5 — Upstream** + - [ ] Target profile in `contracts/spec/platforms.ts` + - [ ] Build backend in dispatch registry + - [ ] Golden tests via sim host + - [ ] Documentation in `docs/` + - [ ] PR to pocket-stack/pocketjs From 79bb269a8ef5b2a1ff955cc47068b0c6d5daac8f Mon Sep 17 00:00:00 2001 From: Alberto Date: Thu, 23 Jul 2026 23:05:48 +0200 Subject: [PATCH 05/13] feat(contracts): add pocketbook target profile + build backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register a `pocketbook` target (hostAbi 4) in POCKET_TARGETS, shaped like vita: a 480x272 logical viewport at rasterDensity 2 (a 960x544 render), integer-fit presentation, and input.buttons + input.touch + text.glyphs.baked capabilities (no analog nub, real capacitive pointer instead of the synthesized cursor). physicalViewport is the nominal 2x surface — not the raw panel, which varies by model and is not an integer multiple of 480x272 — so integer-fit apps validate; the host centers the render on the actual panel. Add the matching `pocketbook` backend to tools/pocket.ts (required by the Record satisfies). The host loads the pak + bundle from the device filesystem, so the backend just confirms the bundle is ready; the host ELF is cross-compiled separately. `bun pocket compile --target pocketbook` now builds hero (and any 480x272 integer-fit app) for the host. --- contracts/spec/platforms.ts | 26 ++++++++++++++++++++++++++ tools/pocket.ts | 11 +++++++++++ 2 files changed, 37 insertions(+) diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index 72a2bf8b..5fe3df83 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -162,6 +162,7 @@ export type PocketCapabilityId = CapabilityId; export const POCKET_TARGETS = defineTargetRegistry; readonly vita: TargetProfile; + readonly pocketbook: TargetProfile; readonly "macos-widget": TargetProfile; }>({ psp: { @@ -201,6 +202,31 @@ export const POCKET_TARGETS = defineTargetRegistry { + // The PocketBook host loads the pak + bundle from the device filesystem + // (hosts/pocketbook), so there is no platform binary to package here — + // `compile` already emitted .js + .pak into outdir. The host ELF + // is cross-compiled separately (cargo zigbuild; see hosts/pocketbook/ + // README.md). A dedicated tools/pocketbook.ts wrapper is future work. + console.log( + `✓ PocketBook bundle ready in ${outdir} — cross-compile the host ` + + `(hosts/pocketbook, cargo zigbuild) and copy both to the device.`, + ); + }, } satisfies Record; await targetBackends[target as PocketTargetId]({ From b16df4016aae934a1b5ddce5b114a5159cbbc04d Mon Sep 17 00:00:00 2001 From: Alberto Date: Thu, 23 Jul 2026 23:06:12 +0200 Subject: [PATCH 06/13] feat(hosts): blit RGB24 for color + gray panels; fix viewport to match bundle Two corrections from targeting real hardware (PocketBook Verse grayscale and Era Color Kaleido-3 color): - Blit RGB24 instead of a host-side Gray8 conversion. inkview's Screen::draw is generic over the pixel format and branches on panel depth: it converts RGB24 -> 8-bit gray internally on grayscale panels and writes RGB directly on color panels, so one blit path serves both. Damage now diffs the RGBA8 raster buffer directly (conservatively over-reports on equal-luminance hue shifts, which is safe). - Present the bundle's fixed 480x272 @2x viewport (a 960x544 render) and integer-fit center it on the panel, instead of auto-deriving a logical viewport from the panel size. The framework lays the app out for the viewport the target profile bakes (480x272), so the host must present exactly that; auto-deriving produced a mismatched 511x379 layout. Also split diff/advance so the blit reads the current frame (the previous swap-after-diff order would have drawn the prior frame). --- hosts/pocketbook/src/framebuffer.rs | 142 +++++++++++++++++----------- hosts/pocketbook/src/main.rs | 87 ++++++++++------- 2 files changed, 138 insertions(+), 91 deletions(-) diff --git a/hosts/pocketbook/src/framebuffer.rs b/hosts/pocketbook/src/framebuffer.rs index 766f8629..27074980 100644 --- a/hosts/pocketbook/src/framebuffer.rs +++ b/hosts/pocketbook/src/framebuffer.rs @@ -1,12 +1,19 @@ -//! RGBA8 → Gray8 conversion + tile-based damage tracking for the e-ink panel. +//! RGBA8 raster buffer + tile-based damage tracking for the e-ink panel. //! //! The core's software rasterizer (`pocketjs_core::raster::render_scaled`) //! produces an RGBA8 framebuffer at `logical × density` resolution, cleared to -//! opaque black with every pixel alpha=255. We luminance-convert to Gray8 (the -//! panel's native 8-bit grayscale) and diff against the previous frame in -//! 16×16 tiles to drive partial panel updates. +//! opaque black with every pixel alpha=255. We diff it against the previous +//! frame in 16×16 tiles to drive partial panel updates, then blit the changed +//! pixels as `RGB24`. +//! +//! inkview's `Screen::draw` is generic over the pixel format and branches on +//! the panel depth: on a grayscale panel (PocketBook Verse) it converts +//! `RGB24` → 8-bit gray internally (the same 0.2125R+0.7154G+0.0721B +//! luminance), while on a color panel (PocketBook Era Color, Kaleido 3) it +//! writes the RGB triple directly. One blit path therefore serves both gray +//! and color devices with no host-side conversion. -use inkview::screen::{BB8, Screen}; +use inkview::screen::{RGB24, Screen}; use pocketjs_core::{Ui, raster}; /// One changed tile, in render-buffer pixel coordinates. @@ -23,8 +30,9 @@ pub struct DirtyRect { const TILE: usize = 16; pub struct FramebufferPipeline { + /// Current frame, RGBA8 (raster output). rgba: Vec, - gray: Vec, + /// Previous frame, RGBA8 (for damage diffing). prev: Vec, w: usize, h: usize, @@ -34,11 +42,10 @@ pub struct FramebufferPipeline { impl FramebufferPipeline { /// `w`/`h` are the RENDER-buffer dimensions (logical × density). pub fn new(w: usize, h: usize, density: u32) -> Self { - let n = w * h; + let n = w * h * 4; Self { - rgba: vec![0u8; n * 4], - gray: vec![255u8; n], // idle white (e-ink holds image without refresh) - prev: vec![255u8; n], + rgba: vec![0u8; n], + prev: vec![0u8; n], w, h, density, @@ -51,31 +58,23 @@ impl FramebufferPipeline { raster::render_scaled(ui, words, &mut self.rgba, self.density); } - /// Luminance-convert RGBA8→Gray8 and return the changed tiles (buffer coords). - pub fn convert_and_diff(&mut self) -> Vec { - let n = self.w * self.h; - for i in 0..n { - let r = self.rgba[i * 4] as u32; - let g = self.rgba[i * 4 + 1] as u32; - let b = self.rgba[i * 4 + 2] as u32; - // Integer form of inkview's 0.2125R + 0.7154G + 0.0721B. - self.gray[i] = ((54 * r + 183 * g + 19 * b) >> 8) as u8; - } - let dirty = self.diff(); - std::mem::swap(&mut self.gray, &mut self.prev); - dirty - } - - fn diff(&self) -> Vec { + /// Diff the current frame against the previous one and return the changed + /// tiles (buffer coordinates). Does NOT advance the previous-frame buffer; + /// call [`Self::advance`] after blitting. + pub fn diff(&self) -> Vec { let (w, h) = (self.w, self.h); let tx = w.div_ceil(TILE); let ty = h.div_ceil(TILE); let mut flags = vec![false; tx * ty]; for y in 0..h { - let row = y * w; for x in 0..w { - let i = row + x; - if self.gray[i] != self.prev[i] { + let i = (y * w + x) * 4; + // Alpha is always 255 (the raster clears opaque), so comparing + // RGB decides visibility; a 4-byte compare would be equivalent. + if self.rgba[i] != self.prev[i] + || self.rgba[i + 1] != self.prev[i + 1] + || self.rgba[i + 2] != self.prev[i + 2] + { flags[(y / TILE) * tx + (x / TILE)] = true; } } @@ -97,13 +96,23 @@ impl FramebufferPipeline { .collect() } - /// Write changed tiles to the inkview framebuffer at screen offset (ox, oy). + /// Latch the current frame as the previous one for the next diff. + pub fn advance(&mut self) { + std::mem::swap(&mut self.rgba, &mut self.prev); + } + + /// Blit changed tiles to the inkview framebuffer at screen offset (ox, oy). /// Issues no panel update — the caller drives that through `refresh`. pub fn blit_dirty(&self, screen: &mut Screen, dirty: &[DirtyRect], ox: usize, oy: usize) { for r in dirty { for y in r.y..(r.y + r.h) { for x in r.x..(r.x + r.w) { - screen.draw(x + ox, y + oy, BB8(self.gray[y * self.w + x])); + let i = (y * self.w + x) * 4; + screen.draw( + x + ox, + y + oy, + RGB24(self.rgba[i], self.rgba[i + 1], self.rgba[i + 2]), + ); } } } @@ -113,7 +122,12 @@ impl FramebufferPipeline { pub fn blit_all(&self, screen: &mut Screen, ox: usize, oy: usize) { for y in 0..self.h { for x in 0..self.w { - screen.draw(x + ox, y + oy, BB8(self.gray[y * self.w + x])); + let i = (y * self.w + x) * 4; + screen.draw( + x + ox, + y + oy, + RGB24(self.rgba[i], self.rgba[i + 1], self.rgba[i + 2]), + ); } } } @@ -123,39 +137,53 @@ impl FramebufferPipeline { mod tests { use super::*; - #[test] - fn luminance_matches_inkview_coefficients() { - // Pure green → 0.7154*255 ≈ 182. - let mut fb = FramebufferPipeline::new(1, 1, 1); - fb.rgba.copy_from_slice(&[0, 255, 0, 255]); - fb.prev[0] = 0; // force a diff so we can observe gray[0] - fb.convert_and_diff(); - // After the swap, prev holds the freshly computed gray. - assert_eq!(fb.prev[0], ((54 * 0 + 183 * 255 + 19 * 0) >> 8) as u8); - assert!((fb.prev[0] as i32 - 182).abs() <= 1); + fn solid(fb: &mut FramebufferPipeline, r: u8, g: u8, b: u8) { + for px in fb.rgba.chunks_exact_mut(4) { + px[0] = r; + px[1] = g; + px[2] = b; + px[3] = 255; + } } #[test] - fn unchanged_frame_has_no_damage() { + fn identical_frames_have_no_damage() { let mut fb = FramebufferPipeline::new(32, 32, 1); - // Make gray == prev (both start white) → no damage. - let dirty = fb.convert_and_diff(); - // raster buffer is black → gray becomes 0, differs from white prev. - assert!(!dirty.is_empty()); - // Second pass with the same black buffer: gray == prev now → no damage. - fb.gray.copy_from_slice(&fb.prev); - let dirty2 = fb.convert_and_diff(); - assert!(dirty2.is_empty()); + solid(&mut fb, 10, 20, 30); + fb.advance(); // prev = current + // Same content again → no diff. + solid(&mut fb, 10, 20, 30); + assert!(fb.diff().is_empty()); + } + + #[test] + fn color_change_is_damage_even_at_equal_luminance() { + // Diffing RGBA (not gray) means a hue shift at constant luminance is + // still reported — conservative (over-reports), which is safe: it only + // causes an extra blit, never a missed update. Matters for color panels. + let mut fb = FramebufferPipeline::new(16, 16, 1); + solid(&mut fb, 255, 0, 0); + fb.advance(); + solid(&mut fb, 0, 0, 255); + assert!(!fb.diff().is_empty()); } #[test] fn damage_reports_tile_bounds() { let mut fb = FramebufferPipeline::new(32, 32, 1); - // Flip one pixel in the second tile column (x=16..31). - fb.gray.copy_from_slice(&fb.prev); - fb.gray[20] = 0; // y=0, x=20 → tile (1, 0) - // diff() compares gray vs prev directly. - let dirty = fb.diff(); - assert_eq!(dirty, vec![DirtyRect { x: 16, y: 0, w: 16, h: 16 }]); + // prev = black; change one pixel in tile column 1 (x=16..31). + fb.advance(); + let i = (0 * 32 + 20) * 4; // y=0, x=20 → tile (1, 0) + fb.rgba[i] = 255; + fb.rgba[i + 3] = 255; + assert_eq!( + fb.diff(), + vec![DirtyRect { + x: 16, + y: 0, + w: 16, + h: 16 + }] + ); } } diff --git a/hosts/pocketbook/src/main.rs b/hosts/pocketbook/src/main.rs index 4e3e6d12..010f75e0 100644 --- a/hosts/pocketbook/src/main.rs +++ b/hosts/pocketbook/src/main.rs @@ -1,8 +1,9 @@ //! pocketbook-host — the PocketJS UI runtime on PocketBook e-readers. //! //! Reuses the backend-agnostic `ui` surface (`pocket_ui_surface::UiSurface`) -//! and the core's software rasterizer, then converts RGBA8 → Gray8 and drives -//! the e-ink panel through inkview. See `pocketjs-inkview-implementation.md` +//! and the core's software rasterizer, then blits the frame as RGB24 (inkview +//! converts to gray on grayscale panels, writes RGB on color panels). See +//! `pocketjs-inkview-implementation.md` //! at the repo root for the full design and the ground-truth API notes. //! //! Event-loop model (mirrors `inkview-slint`): `iv_main` runs on the main @@ -34,9 +35,16 @@ const HOST_ABI: u32 = 4; /// smooth while sparing CPU and battery. const TICK_MS: u64 = 33; -/// Touch packs coordinates into 9 bits (framework/src/touch.ts), so the -/// logical viewport must stay ≤511 px per axis. -const MAX_LOGICAL_AXIS: usize = 511; +/// Logical viewport the pocketbook target profile bakes bundles for +/// (contracts/spec/platforms.ts). Must match the bundle: the framework lays +/// the app out for this size, so the host presents exactly it. +const LOGICAL_W: u32 = 480; +const LOGICAL_H: u32 = 272; +/// Raster density the target profile bakes font atlases/images at; the host +/// must render at the same density for crisp output. 480×272 also stays +/// ≤511 px/axis, keeping touch coordinates inside the 9-bit wire format +/// (framework/src/touch.ts). +const DENSITY: u32 = 2; fn main() -> Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); @@ -80,7 +88,7 @@ fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver) -> Re let phys_w = screen.width(); let phys_h = screen.height(); - let geo = Geometry::choose(phys_w, phys_h); + let geo = Geometry::for_panel(phys_w, phys_h); log::info!( "pocketbook: panel {phys_w}x{phys_h}, logical {}x{} @{}x, render {}x{} +({},{})", geo.logical_w, @@ -97,10 +105,8 @@ fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver) -> Re let bundle = std::fs::read_to_string(js_path()).with_context(|| format!("reading {}", js_path()))?; - let surface = UiSurface::new_with_density( - (geo.logical_w as f32, geo.logical_h as f32), - geo.density, - ); + let surface = + UiSurface::new_with_density((geo.logical_w as f32, geo.logical_h as f32), geo.density); surface.set_identity(HOST_ID, HOST_ABI); surface.feed_pak(&pak); @@ -124,7 +130,14 @@ fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver) -> Re // First paint: render one frame and full-update so the screen starts clean. tick( - &guest, &surface, &mut fb, &mut refresh, &mut input, &mut screen, &geo, true, + &guest, + &surface, + &mut fb, + &mut refresh, + &mut input, + &mut screen, + &geo, + true, )?; let mut last_tick = Instant::now(); @@ -167,7 +180,14 @@ fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver) -> Re last_tick = Instant::now(); tick( - &guest, &surface, &mut fb, &mut refresh, &mut input, &mut screen, &geo, full, + &guest, + &surface, + &mut fb, + &mut refresh, + &mut input, + &mut screen, + &geo, + full, )?; } Ok(()) @@ -191,20 +211,21 @@ fn tick( surface.tick(); - // Rasterize at density → RGBA8, then luminance → Gray8 + damage (buffer coords). + // Rasterize at density → RGBA8 and diff against the previous frame + // (buffer coords). The blit below reads the CURRENT frame, so the + // previous-frame latch is advanced only after blitting. let dirty = surface.with_ui(|ui| { let words = ui.draw().words.clone(); fb.rasterize(ui, &words); - fb.convert_and_diff() + fb.diff() }); if full { + // Full redraw (first paint / return from background): blit everything + // and flash the panel once for a clean, ghost-free image. fb.blit_all(screen, geo.ox, geo.oy); refresh.full(screen); - return Ok(()); - } - - if !dirty.is_empty() { + } else if !dirty.is_empty() { fb.blit_dirty(screen, &dirty, geo.ox, geo.oy); let screen_dirty = offset_rects(&dirty, geo.ox, geo.oy); refresh.present(screen, &screen_dirty); @@ -213,6 +234,9 @@ fn tick( // cleanup timer (it no-ops when there's nothing pending). refresh.present(screen, &[]); } + + // Latch this frame as the previous one for the next diff. + fb.advance(); Ok(()) } @@ -241,27 +265,22 @@ struct Geometry { } impl Geometry { - /// Pick a raster density (env `POCKET_DENSITY`, default 2) and the largest - /// logical viewport that (a) integer-scales by the density onto the panel - /// and (b) stays ≤511 px/axis so touch coordinates fit the 9-bit wire - /// format. The render buffer is integer-fit centered on the panel. - fn choose(phys_w: usize, phys_h: usize) -> Self { - let density = std::env::var("POCKET_DENSITY") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(2) - .clamp(1, 4); - let d = density as usize; - let logical_w = (phys_w / d).clamp(1, MAX_LOGICAL_AXIS); - let logical_h = (phys_h / d).clamp(1, MAX_LOGICAL_AXIS); - let render_w = logical_w * d; - let render_h = logical_h * d; + /// Present the bundle's fixed 480×272 @2x surface (a 960×544 render) and + /// integer-fit center it on the actual panel. The panel size only sets the + /// centering offset; the logical viewport and density are fixed to match + /// the pocketbook target profile (and stay ≤511/axis, so touch coordinates + /// fit the 9-bit wire format). + fn for_panel(phys_w: usize, phys_h: usize) -> Self { + let logical_w = LOGICAL_W as usize; + let logical_h = LOGICAL_H as usize; + let render_w = logical_w * DENSITY as usize; + let render_h = logical_h * DENSITY as usize; let ox = phys_w.saturating_sub(render_w) / 2; let oy = phys_h.saturating_sub(render_h) / 2; Self { logical_w, logical_h, - density, + density: DENSITY, render_w, render_h, ox, From 26d6fbd2a81efa9f55864280c8c671ce3186d7f7 Mon Sep 17 00:00:00 2001 From: Alberto Date: Thu, 23 Jul 2026 23:06:37 +0200 Subject: [PATCH 07/13] feat(hosts): ARM cross-build for the pocketbook host (bindgen + glibc shim) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes surfaced by `cargo zigbuild --target armv7-unknown-linux-gnueabi.2.23`: - rquickjs ships pre-generated FFI bindings for common targets but not the soft-float armv7-unknown-linux-gnueabi that PocketBook needs, so enable its bindgen feature for ARM builds only (target-specific dep; needs libclang). Native builds keep the pre-generated bindings. - LLVM 19+ lowers f32::max/min to C23 math symbols (fmaximum_numf, fminimum_num, …) that PocketBook's glibc 2.23 predates, failing the link. build.rs compiles a small shim (src/compat.c) providing those symbols for the cross-build only (glibc 2.23 defines none of them, so no clash); native builds use the system libm. Produces a stripped 2.3M ARM EABI5 ELF requiring only glibc <=2.18, dlopening libinkview.so at runtime. --- hosts/pocketbook/Cargo.lock | 221 +++++++++++++++++++++++++++++++++- hosts/pocketbook/Cargo.toml | 15 ++- hosts/pocketbook/build.rs | 18 +++ hosts/pocketbook/src/compat.c | 35 ++++++ 4 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 hosts/pocketbook/build.rs create mode 100644 hosts/pocketbook/src/compat.c diff --git a/hosts/pocketbook/Cargo.lock b/hosts/pocketbook/Cargo.lock index 171fd323..93c03e12 100644 --- a/hosts/pocketbook/Cargo.lock +++ b/hosts/pocketbook/Cargo.lock @@ -85,12 +85,38 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "cc" version = "1.3.0" @@ -98,7 +124,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -107,19 +142,39 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "defmt" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ - "bitflags", + "bitflags 1.3.2", "defmt-macros", ] @@ -144,6 +199,12 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "env_filter" version = "2.0.0" @@ -179,12 +240,24 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "grid" version = "1.0.1" @@ -202,6 +275,22 @@ dependencies = [ "foldhash", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inkview" version = "0.3.0" @@ -218,6 +307,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "jiff" version = "0.2.34" @@ -254,6 +352,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "libloading" version = "0.8.9" @@ -276,6 +380,22 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -327,12 +447,14 @@ name = "pocketbook-host" version = "0.1.0" dependencies = [ "anyhow", + "cc", "env_logger", "inkview", "log", "pocket-mod", "pocket-ui-surface", "pocketjs-core", + "rquickjs", ] [[package]] @@ -357,6 +479,25 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -420,6 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ce6b626d30ecbedaaf8097a04982bc3b081f958f5bdf9b8796be4ac00ee48bb" dependencies = [ "rquickjs-core", + "rquickjs-macro", ] [[package]] @@ -433,15 +575,39 @@ dependencies = [ "rquickjs-sys", ] +[[package]] +name = "rquickjs-macro" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7204086521740ca03f33036579801871aa5b714a0e7f68b4c2e98d68b0380bfb" +dependencies = [ + "convert_case", + "fnv", + "ident_case", + "indexmap", + "proc-macro-crate", + "proc-macro2", + "quote", + "rquickjs-core", + "syn 2.0.119", +] + [[package]] name = "rquickjs-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eef73520804cf5aa4876097ac5733058d628c769eba9678e0f4dba5a4ef79703" dependencies = [ + "bindgen", "cc", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "serde" version = "1.0.229" @@ -472,6 +638,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -561,12 +733,48 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "utf8parse" version = "0.2.2" @@ -593,3 +801,12 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] diff --git a/hosts/pocketbook/Cargo.toml b/hosts/pocketbook/Cargo.toml index 5be5241f..12a6c718 100644 --- a/hosts/pocketbook/Cargo.toml +++ b/hosts/pocketbook/Cargo.toml @@ -3,8 +3,9 @@ # STANDALONE LONE-BIN CRATE (same model as hosts/psp and hosts/vita): it lives # outside any workspace with its own Cargo.lock, and path-depends on the engine # crates. It reuses the backend-agnostic `ui` surface (pocket-ui-surface) and -# the core's software rasterizer, then converts RGBA8 → Gray8 for the e-ink -# panel. See pocketjs-inkview-implementation.md at the repo root. +# the core's software rasterizer, then blits the frame as RGB24 (inkview +# converts to gray on grayscale panels, writes RGB on color panels). +# See pocketjs-inkview-implementation.md at the repo root. # # Cross-compile for PocketBook's ARM Linux (glibc 2.23): # rustup target add armv7-unknown-linux-gnueabi @@ -35,6 +36,16 @@ anyhow = "1" log = "0.4" env_logger = "0.11" +# rquickjs ships pre-generated FFI bindings for common targets but NOT the +# soft-float `armv7-unknown-linux-gnueabi` that PocketBook needs, so generate +# them at build time (bindgen; requires libclang) for ARM cross-builds only. +# Native builds keep the pre-generated bindings (no libclang required). +[target.'cfg(target_arch = "arm")'.dependencies] +rquickjs = { version = "0.12", features = ["bindgen"] } + +[build-dependencies] +cc = "1" + [profile.release] opt-level = "s" # size-optimized (e-readers have limited storage) lto = true diff --git a/hosts/pocketbook/build.rs b/hosts/pocketbook/build.rs new file mode 100644 index 00000000..84f55d9c --- /dev/null +++ b/hosts/pocketbook/build.rs @@ -0,0 +1,18 @@ +//! Links a small C shim (src/compat.c) providing the C23 math symbols +//! (`fmaximum_numf`, `fminimum_numf`, …) that LLVM 19+ lowers `f32::max/min` +//! to but that PocketBook's glibc 2.23 predates. Compiled for the target via +//! the `cc` crate, which cargo-zigbuild routes through zig for the cross-build. +//! +//! Gated to the exact PocketBook target: native builds use the system libm +//! (which already provides these symbols), so compiling the shim there would +//! risk a duplicate-symbol clash. + +fn main() { + println!("cargo:rerun-if-changed=src/compat.c"); + let target = std::env::var("TARGET").unwrap_or_default(); + if target == "armv7-unknown-linux-gnueabi" { + cc::Build::new() + .file("src/compat.c") + .compile("pocketbook_compat"); + } +} diff --git a/hosts/pocketbook/src/compat.c b/hosts/pocketbook/src/compat.c new file mode 100644 index 00000000..5c803222 --- /dev/null +++ b/hosts/pocketbook/src/compat.c @@ -0,0 +1,35 @@ +/* C23 math symbols that LLVM 19+ lowers f32/f64::max/min (and maximum/minimum) + * to, but that PocketBook's glibc 2.23 predates — linking the host otherwise + * fails with `undefined symbol: fmaximum_numf` & friends. + * + * The `_num` variants are NaN-suppressing (return the non-NaN operand), exactly + * matching Rust's f32::max/min, so fmax/fmin are a precise shim. The plain + * variants are NaN-propagating (Rust's f32::maximum/minimum); the rasterizer + * never feeds them NaN, but we implement the propagation anyway for correctness. + * + * Only compiled for the armv7-unknown-linux-gnueabi cross-build (see build.rs); + * glibc 2.23 defines none of these, so there is no duplicate-symbol clash. + */ +#include + +float fmaximum_numf(float a, float b) { return fmaxf(a, b); } +float fminimum_numf(float a, float b) { return fminf(a, b); } +double fmaximum_num(double a, double b) { return fmax(a, b); } +double fminimum_num(double a, double b) { return fmin(a, b); } + +float fmaximumf(float a, float b) { + if (isnan(a) || isnan(b)) return NAN; + return a > b ? a : b; +} +float fminimumf(float a, float b) { + if (isnan(a) || isnan(b)) return NAN; + return a < b ? a : b; +} +double fmaximum(double a, double b) { + if (isnan(a) || isnan(b)) return NAN; + return a > b ? a : b; +} +double fminimum(double a, double b) { + if (isnan(a) || isnan(b)) return NAN; + return a < b ? a : b; +} From 6ffb3b44827e4b01d9b63a2c29343de59aa444a0 Mon Sep 17 00:00:00 2001 From: Alberto Date: Thu, 23 Jul 2026 23:07:06 +0200 Subject: [PATCH 08/13] docs(hosts): pocketbook deploy script + device testing checklist deploy.sh installs the cross-compiled host + a built app bundle into applications// on a USB-mounted PocketBook (auto-detects the mount point). README updated with the verified build steps (zigbuild, bindgen + libclang, the glibc math shim), the color/gray blit behavior, and a per-device checklist covering boot/render, touch + key input, and e-ink refresh for both the grayscale Verse and the color Era Color. --- hosts/pocketbook/README.md | 114 +++++++++++++++++++++++++++++-------- hosts/pocketbook/deploy.sh | 67 ++++++++++++++++++++++ 2 files changed, 158 insertions(+), 23 deletions(-) create mode 100755 hosts/pocketbook/deploy.sh diff --git a/hosts/pocketbook/README.md b/hosts/pocketbook/README.md index 669577d6..b27a7b15 100644 --- a/hosts/pocketbook/README.md +++ b/hosts/pocketbook/README.md @@ -6,58 +6,126 @@ The PocketJS UI runtime on **PocketBook e-readers**, rendered through the It reuses the backend-agnostic `ui` surface (`pocket-ui-surface`) and the core's software rasterizer unchanged, then: -- rasterizes the DrawList to RGBA8 at `logical × density` resolution - (`pocketjs_core::raster::render_scaled`); -- luminance-converts RGBA8 → Gray8 with 16×16-tile damage tracking - (`framebuffer.rs`); -- drives the e-ink panel with a partial/dynamic/full update policy ported from +- rasterizes the DrawList to RGBA8 at `480×272 @2x` = 960×544 + (`pocketjs_core::raster::render_scaled`), matching the `pocketbook` target + profile in `contracts/spec/platforms.ts`; +- diffs frames in 16×16 tiles and blits the changed pixels as `RGB24` + (`framebuffer.rs`). inkview's `Screen::draw` converts `RGB24`→Gray8 + internally on **grayscale** panels (PocketBook Verse) and writes RGB directly + on **color** panels (PocketBook Era Color, Kaleido 3) — one blit path serves + both; +- drives the panel with a partial/dynamic/full update policy ported from `inkview-slint` (`refresh.rs`); - maps inkview keys → the spec BTN bitmask and the touchscreen → the framework's packed touch wire format (`input.rs`); - runs the inkview event loop on the main thread forwarding into a channel, with a second thread owning the `Screen` and the fixed-cadence tick/render loop - (`main.rs`, same model as the `inkview-slint` demo). + (`main.rs`, the `inkview-slint` demo model). + +The 960×544 render is integer-fit centered on the actual panel (which varies by +model), so the host works across devices without per-model configuration. See **`pocketjs-inkview-implementation.md`** at the repo root for the full -design, the ground-truth API notes, and the open design questions (notably the -logical-viewport / touch-coordinate trade-off in §9). +design and the ground-truth API notes. ## Status -Phases 0–3 of the implementation plan: the host compiles natively, is clippy -clean, and its framebuffer/input unit tests pass. On-device validation -(refresh tuning, touch accuracy, the §9 viewport choice) still requires -hardware. +The host cross-compiles to a stripped ARM ELF (glibc ≤2.18, dlopens +`libinkview.so` at runtime), is clippy-clean, and its framebuffer/input unit +tests pass. The `pocketbook` target is registered and `hero` builds for it +(`bun pocket compile --target pocketbook`). **On-device validation still needs +hardware** — see the checklist below. ## Build -Cross-compile for PocketBook's ARM Linux (glibc 2.23): +One-time toolchain setup: ```sh rustup target add armv7-unknown-linux-gnueabi cargo install cargo-zigbuild +# zig (brew install zig) and libclang (for rquickjs bindgen) are also required. +``` + +Cross-compile the host (from this directory): + +```sh cargo zigbuild --release --target armv7-unknown-linux-gnueabi.2.23 +# → target/armv7-unknown-linux-gnueabi/release/pocketbook-host ``` -`libinkview.so` is `dlopen`'d at runtime (`inkview::load`) — no SDK is needed at -build time. The `inkview` dependency currently points at a local checkout; switch -to the git dependency in `Cargo.toml` for a standalone/CI build. +Notes: + +- `libinkview.so` is `dlopen`'d at runtime (`inkview::load`) — no SDK at build + time. +- `rquickjs` ships pre-generated FFI bindings for common targets but not the + soft-float `armv7-unknown-linux-gnueabi`, so the ARM build enables its + `bindgen` feature (needs `libclang`). Native builds use the pre-generated + bindings. +- LLVM lowers `f32::max/min` to C23 math symbols (`fmaximum_numf`, …) that + PocketBook's glibc 2.23 predates; `build.rs` links a tiny shim + (`src/compat.c`) providing them for the cross-build. +- The `inkview` dependency currently points at a local checkout; switch to the + git dependency in `Cargo.toml` for a standalone/CI build. + +## Build the app bundle + +From the repo root: + +```sh +bun pocket compile --target pocketbook --manifest apps/hero/pocket.json --project-root . +# → dist/hero-main.js + dist/hero-main.pak +``` ## Deploy +Connect the PocketBook over USB, then from the repo root: + ```sh -D=/mnt/ext1/applications/myapp -cp target/armv7-unknown-linux-gnueabi.2.23/release/pocketbook-host $D/myapp -cp dist/.js $D/app.js # PocketJS build output -cp dist/.pak $D/app.pak -chmod +x $D/myapp +hosts/pocketbook/deploy.sh # auto-detects the mount point +# or explicitly: +hosts/pocketbook/deploy.sh /run/media/$USER/PB626 ``` +It installs `applications/pocketjs-hero/{pocketjs-hero, app.js, app.pak}`. +Eject safely and launch **pocketjs-hero** from the launcher (a firmware rescan +or restart may be needed for a new app to appear). + ## Runtime configuration | Env var | Default | Meaning | | ------- | ------- | ------- | | `POCKET_PAK` | `app.pak` | path to the app pak | | `POCKET_JS` | `app.js` | path to the JS bundle | -| `POCKET_DENSITY` | `2` | raster density (1–4); raise for high-DPI panels so the logical viewport stays ≤511 px/axis | -| `RUST_LOG` | `info` | log filter | +| `RUST_LOG` | `info` | log filter (the host logs the panel size + geometry at startup) | + +## Device testing checklist + +Run on both a grayscale and a color device to exercise both blit paths. + +**Boot / render** + +- [ ] App appears in the launcher and opens without crashing. +- [ ] The `hero` UI renders, centered, with letterbox borders on the larger + panel. Check the startup log line (`pocketbook: panel WxH, … + (ox,oy)`) + for sane geometry. +- [ ] Text is crisp (font atlases are baked @2x). +- [ ] **Verse (gray):** image/logo render in grayscale, no color. +- [ ] **Era Color (color):** colored UI elements actually show color. + +**Input** + +- [ ] Touch: tapping a button activates it (touch maps physical→logical via the + integer-fit offset + density). +- [ ] Hardware keys: D-pad moves focus, OK activates, Back/Menu behave. +- [ ] Page-turn keys (Prev/Next) map to left/right. + +**E-ink refresh** + +- [ ] Small changes (button highlight) update without a full flash. +- [ ] During animation/scroll the panel keeps up (dynamic updates), then does a + clean partial update when it settles (~200 ms quiet). +- [ ] No persistent ghosting after a few seconds idle (periodic cleanup works). +- [ ] Returning from background (`Show`) does one clean full redraw. + +**Report back** any crash (ideally with `RUST_LOG=debug` output), mis-render, +touch offset, or excessive flicker — those drive the next iteration. diff --git a/hosts/pocketbook/deploy.sh b/hosts/pocketbook/deploy.sh new file mode 100755 index 00000000..2ae4f39e --- /dev/null +++ b/hosts/pocketbook/deploy.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Deploy the PocketBook host + a demo app to a device mounted over USB. +# +# Usage: +# hosts/pocketbook/deploy.sh [MOUNT_POINT] [APP_NAME] +# +# MOUNT_POINT device mount root (default: auto-detect the first PocketBook +# under /run/media/$USER, /media/$USER, or /Volumes on macOS) +# APP_NAME launcher folder name (default: pocketjs-hero) +# +# Prereqs (run from the repo root): +# # 1. cross-compile the host +# (cd hosts/pocketbook && cargo zigbuild --release --target armv7-unknown-linux-gnueabi.2.23) +# # 2. build the app bundle for the pocketbook target +# bun pocket compile --target pocketbook --manifest apps/hero/pocket.json --project-root . +# +# The host reads app.js + app.pak from its working directory (override with +# POCKET_JS / POCKET_PAK), so we install them next to the binary. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +HOST_BIN="$ROOT/hosts/pocketbook/target/armv7-unknown-linux-gnueabi/release/pocketbook-host" +BUNDLE_JS="$ROOT/dist/hero-main.js" +BUNDLE_PAK="$ROOT/dist/hero-main.pak" + +APP_NAME="${2:-pocketjs-hero}" + +# --- locate the device mount point ----------------------------------------- +detect_mount() { + local bases=("/run/media/${USER:-$USER}" "/media/${USER:-$USER}" "/Volumes") + for base in "${bases[@]}"; do + [ -d "$base" ] || continue + # A PocketBook exposes an `applications/` dir at its storage root. + for cand in "$base"/*; do + [ -d "$cand/applications" ] && { echo "$cand"; return; } + done + done + return 1 +} + +MOUNT="${1:-}" +if [ -z "$MOUNT" ]; then + MOUNT="$(detect_mount || true)" +fi +if [ -z "$MOUNT" ] || [ ! -d "$MOUNT" ]; then + echo "error: no device mount point found. Pass it explicitly:" >&2 + echo " $0 /run/media/\$USER/PB626" >&2 + exit 1 +fi + +# --- sanity-check artifacts ------------------------------------------------- +[ -f "$HOST_BIN" ] || { echo "error: host binary missing — run cargo zigbuild first ($HOST_BIN)" >&2; exit 1; } +[ -f "$BUNDLE_JS" ] || { echo "error: $BUNDLE_JS missing — run 'bun pocket compile --target pocketbook …' first" >&2; exit 1; } +[ -f "$BUNDLE_PAK" ] || { echo "error: $BUNDLE_PAK missing — run 'bun pocket compile --target pocketbook …' first" >&2; exit 1; } + +DEST="$MOUNT/applications/$APP_NAME" +echo "==> deploying to $DEST" +mkdir -p "$DEST" + +cp "$HOST_BIN" "$DEST/$APP_NAME" +cp "$BUNDLE_JS" "$DEST/app.js" +cp "$BUNDLE_PAK" "$DEST/app.pak" +chmod +x "$DEST/$APP_NAME" + +echo "==> done. Eject safely, then launch '$APP_NAME' from the PocketBook launcher." +echo " (If it doesn't appear, the firmware may need a rescan/restart.)" From d3209c91694288796dff08e8a1b0ecb7b4e19bd9 Mon Sep 17 00:00:00 2001 From: Alberto Date: Fri, 24 Jul 2026 09:53:17 +0200 Subject: [PATCH 09/13] feat(pocketbook): scale-to-fit viewport + .app launcher + log capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add nearest-neighbor scale-to-fit in Geometry::for_panel so the 960×544 render is downscaled to fit panels narrower than the render buffer (e.g. Verse 758×1024 → disp 758×429, centered). - Framebuffer blit_all/blit_dirty now iterate screen pixels and sample from the render buffer via inverse mapping. - Touch input maps screen→logical through disp_w/disp_h instead of dividing by density, so taps land correctly under scaling. - deploy.sh creates the .app launcher script (required for PocketBook firmware to discover the app) and redirects stdout/stderr to pocketjs.log in the app directory for post-mortem debugging. - Whitespace normalization in platforms.ts and pocket.ts. --- contracts/spec/platforms.ts | 375 +++++++++++++++------------- hosts/pocketbook/deploy.sh | 60 +++-- hosts/pocketbook/src/framebuffer.rs | 44 ++-- hosts/pocketbook/src/input.rs | 42 +++- hosts/pocketbook/src/main.rs | 100 ++++++-- tools/pocket.ts | 198 ++++++++------- 6 files changed, 483 insertions(+), 336 deletions(-) diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index 5fe3df83..9161f07c 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -9,13 +9,21 @@ export type CapabilityRegistry = readonly string[]; -export function defineCapabilityRegistry(registry: T): T { - return registry; +export function defineCapabilityRegistry( + registry: T, +): T { + return registry; } export type CapabilityId = T[number]; -export const PRESENTATION_MODES = ["fill", "fit", "integer-fit", "native", "stretch"] as const; +export const PRESENTATION_MODES = [ + "fill", + "fit", + "integer-fit", + "native", + "stretch", +] as const; export type PresentationMode = (typeof PRESENTATION_MODES)[number]; export type Viewport = readonly [width: number, height: number]; @@ -35,7 +43,13 @@ export type Viewport = readonly [width: number, height: number]; * has a fixed screen (`dynamicViewport` forbidden). Apps declare viewport * variants per policy, not per target. */ -export const TARGET_FORMS = ["takeover", "window", "widget", "kiosk", "embedded"] as const; +export const TARGET_FORMS = [ + "takeover", + "window", + "widget", + "kiosk", + "embedded", +] as const; export type TargetForm = (typeof TARGET_FORMS)[number]; /** @@ -63,90 +77,92 @@ export type ExecutionClass = (typeof EXECUTION_CLASSES)[number]; export const DYNAMIC_FORMS: readonly TargetForm[] = ["window", "widget"]; export interface DisplayProfile { - readonly physicalViewport: Viewport; - readonly logicalViewports: readonly Viewport[]; - /** - * Present exactly when the target's form is dynamic (window/widget): any - * logical size within [min, max] is admissible, and the host resizes the - * core live (`display.viewport.live`). `logicalViewports` then lists the - * DEFAULT size a plan bakes assets for. `acceptsFixed` opts the target - * into hosting fixed-viewport apps in a size-locked window (an app-form - * host would set it; a widget shell is not a general app frame and - * leaves it off). - */ - readonly dynamicViewport?: { - readonly min: Viewport; - readonly max: Viewport; - readonly acceptsFixed?: boolean; - }; - readonly presentations: readonly PresentationMode[]; - /** - * Target raster samples per logical pixel for baked text, vectors, masks, - * and target-selected image variants. This is a rendering contract, not an - * API capability or a promise that presentation scale has the same value. - */ - readonly rasterDensity: number; + readonly physicalViewport: Viewport; + readonly logicalViewports: readonly Viewport[]; + /** + * Present exactly when the target's form is dynamic (window/widget): any + * logical size within [min, max] is admissible, and the host resizes the + * core live (`display.viewport.live`). `logicalViewports` then lists the + * DEFAULT size a plan bakes assets for. `acceptsFixed` opts the target + * into hosting fixed-viewport apps in a size-locked window (an app-form + * host would set it; a widget shell is not a general app frame and + * leaves it off). + */ + readonly dynamicViewport?: { + readonly min: Viewport; + readonly max: Viewport; + readonly acceptsFixed?: boolean; + }; + readonly presentations: readonly PresentationMode[]; + /** + * Target raster samples per logical pixel for baked text, vectors, masks, + * and target-selected image variants. This is a rendering contract, not an + * API capability or a promise that presentation scale has the same value. + */ + readonly rasterDensity: number; } export interface TargetProfile { - /** JS/native HostOps wire generation embedded by the selected backend. */ - readonly hostAbi: number; - /** Device/OS substrate ("psp", "vita", "macos", …). Queryable data — the - * target id is only a label. */ - readonly platform: string; - /** Shell posture (see TARGET_FORMS). */ - readonly form: TargetForm; - readonly display: DisplayProfile; - /** Framework APIs implemented and tested by this stock host. */ - readonly capabilities: readonly C[]; + /** JS/native HostOps wire generation embedded by the selected backend. */ + readonly hostAbi: number; + /** Device/OS substrate ("psp", "vita", "macos", …). Queryable data — the + * target id is only a label. */ + readonly platform: string; + /** Shell posture (see TARGET_FORMS). */ + readonly form: TargetForm; + readonly display: DisplayProfile; + /** Framework APIs implemented and tested by this stock host. */ + readonly capabilities: readonly C[]; } -export type TargetRegistry = Readonly>>; +export type TargetRegistry = Readonly< + Record> +>; export function defineTargetRegistry< - C extends string, - const T extends TargetRegistry, + C extends string, + const T extends TargetRegistry, >(registry: T): T { - return registry; + return registry; } export type TargetId = Extract; export const POCKET_CAPABILITIES = defineCapabilityRegistry([ - "input.analog.left", - "input.buttons", - // Framework-synthesized pointer for targets without a native one: the - // analog nub steers a screen cursor, hover applies `focus:`, the press - // button applies `active:` and clicks on release. Opt-in per app - // (enableCursor + pocket.json requires/enhances) — d-pad focus traversal - // remains the portable default interaction. - "input.cursor", - // OS text composition: preedit renders inline at the caret with its own - // cursor, commits arrive as ordinary text insertions, and the host docks - // the candidate window at the caret rect the app reports. Distinct from - // input.text — a host can have a keyboard without an IME. - "input.ime", - // A REAL absolute pointer (mouse/trackpad): position plus press/drag/ - // release edges, hover resolves focus. A different guarantee than - // input.cursor's synthesized nub-pointer, hence a different id (see the - // header rule). - "input.pointer", - // A hardware text stream: layout-applied characters plus named editing - // keys (Backspace/Enter/arrows/Home/End/…), key repeat included. The OSK - // is the fallback spelling on targets without it. - "input.text", - "input.touch", - // Copy/cut/paste round-trips with the OS clipboard. - "host.clipboard", - // The logical viewport is runtime-mutable: the app is told about live - // window resizes and relayouts (framework resizeViewport). Console - // targets never provide this — their viewport is a platform constant. - "display.viewport.live", - "text.glyphs.baked", - // Codepoints outside the baked charset still render: the host extends - // the font atlases at runtime (system-font rasterization + loadFontAtlas - // reload). Required by any app that accepts arbitrary text input. - "text.glyphs.runtime", + "input.analog.left", + "input.buttons", + // Framework-synthesized pointer for targets without a native one: the + // analog nub steers a screen cursor, hover applies `focus:`, the press + // button applies `active:` and clicks on release. Opt-in per app + // (enableCursor + pocket.json requires/enhances) — d-pad focus traversal + // remains the portable default interaction. + "input.cursor", + // OS text composition: preedit renders inline at the caret with its own + // cursor, commits arrive as ordinary text insertions, and the host docks + // the candidate window at the caret rect the app reports. Distinct from + // input.text — a host can have a keyboard without an IME. + "input.ime", + // A REAL absolute pointer (mouse/trackpad): position plus press/drag/ + // release edges, hover resolves focus. A different guarantee than + // input.cursor's synthesized nub-pointer, hence a different id (see the + // header rule). + "input.pointer", + // A hardware text stream: layout-applied characters plus named editing + // keys (Backspace/Enter/arrows/Home/End/…), key repeat included. The OSK + // is the fallback spelling on targets without it. + "input.text", + "input.touch", + // Copy/cut/paste round-trips with the OS clipboard. + "host.clipboard", + // The logical viewport is runtime-mutable: the app is told about live + // window resizes and relayouts (framework resizeViewport). Console + // targets never provide this — their viewport is a platform constant. + "display.viewport.live", + "text.glyphs.baked", + // Codepoints outside the baked charset still render: the host extends + // the font atlases at runtime (system-font rasterization + loadFontAtlas + // reload). Required by any app that accepts arbitrary text input. + "text.glyphs.runtime", ] as const); export type PocketCapabilityId = CapabilityId; @@ -159,122 +175,121 @@ export type PocketCapabilityId = CapabilityId; * and no tooling may parse an id — platform/form are queryable fields on the * profile. */ -export const POCKET_TARGETS = defineTargetRegistry; - readonly vita: TargetProfile; - readonly pocketbook: TargetProfile; - readonly "macos-widget": TargetProfile; -}>({ - psp: { - hostAbi: 1, - platform: "psp", - form: "takeover", - display: { - physicalViewport: [480, 272], - logicalViewports: [[480, 272]], - // integer-fit at scale 1 is the portable spelling of the native PSP - // surface and can be satisfied unchanged by higher-resolution hosts. - presentations: ["native", "integer-fit"], - rasterDensity: 1, - }, - capabilities: [ - "input.analog.left", - "input.buttons", - "input.cursor", - "text.glyphs.baked", - ], - }, - vita: { - hostAbi: 2, - platform: "vita", - form: "takeover", - display: { - physicalViewport: [960, 544], - logicalViewports: [[480, 272]], - presentations: ["integer-fit"], - rasterDensity: 2, - }, - capabilities: [ - "input.analog.left", - "input.buttons", - "input.cursor", - "input.touch", - "text.glyphs.baked", - ], - }, - // PocketBook e-readers (inkview): hosts/pocketbook reuses the backend- - // agnostic ui surface + the core software rasterizer. Same logical viewport - // and density as vita (480×272 @2x → a 960×544 render). physicalViewport is - // that nominal 2x surface — NOT the raw panel — so integer-fit apps validate - // (real panels vary by model, e.g. Verse 1024×758, Era Color 1264×1680, and - // are not integer multiples of 480×272); the host queries the actual panel at - // runtime and integer-fit centers the 960×544 render on it. No analog nub; - // a REAL capacitive pointer instead of the synthesized cursor. Color panels - // (Era Color) blit RGB, grayscale panels (Verse) blit Gray8. - pocketbook: { - hostAbi: 4, - platform: "pocketbook", - form: "takeover", - display: { - physicalViewport: [960, 544], - logicalViewports: [[480, 272]], - presentations: ["integer-fit"], - rasterDensity: 2, - }, - capabilities: [ - "input.buttons", - "input.touch", - "text.glyphs.baked", - ], - }, - // The flat pocket-widget shell (examples/note-widget is the stock host): - // a resizable always-on-top window whose logical viewport IS the window, - // rendered at density 2 for Retina. No nub, no synthesized cursor — the - // pointer is real, text comes from the keyboard/IME, and unseen glyphs - // bake at runtime. A widget shell is not a general app frame, so it does - // not accept fixed-viewport apps (a future macos-app target would). - "macos-widget": { - hostAbi: 3, - platform: "macos", - form: "widget", - display: { - physicalViewport: [840, 1120], - logicalViewports: [[420, 560]], - dynamicViewport: { min: [240, 180], max: [4096, 4096] }, - presentations: ["native"], - rasterDensity: 2, - }, - capabilities: [ - "input.buttons", - "input.ime", - "input.pointer", - "input.text", - "host.clipboard", - "display.viewport.live", - "text.glyphs.baked", - "text.glyphs.runtime", - ], - }, +export const POCKET_TARGETS = defineTargetRegistry< + PocketCapabilityId, + { + readonly psp: TargetProfile; + readonly vita: TargetProfile; + readonly pocketbook: TargetProfile; + readonly "macos-widget": TargetProfile; + } +>({ + psp: { + hostAbi: 1, + platform: "psp", + form: "takeover", + display: { + physicalViewport: [480, 272], + logicalViewports: [[480, 272]], + // integer-fit at scale 1 is the portable spelling of the native PSP + // surface and can be satisfied unchanged by higher-resolution hosts. + presentations: ["native", "integer-fit"], + rasterDensity: 1, + }, + capabilities: [ + "input.analog.left", + "input.buttons", + "input.cursor", + "text.glyphs.baked", + ], + }, + vita: { + hostAbi: 2, + platform: "vita", + form: "takeover", + display: { + physicalViewport: [960, 544], + logicalViewports: [[480, 272]], + presentations: ["integer-fit"], + rasterDensity: 2, + }, + capabilities: [ + "input.analog.left", + "input.buttons", + "input.cursor", + "input.touch", + "text.glyphs.baked", + ], + }, + // PocketBook e-readers (inkview): hosts/pocketbook reuses the backend- + // agnostic ui surface + the core software rasterizer. Same logical viewport + // and density as vita (480×272 @2x → a 960×544 render). physicalViewport is + // that nominal 2x surface — NOT the raw panel — so integer-fit apps validate + // (real panels vary by model, e.g. Verse 1024×758, Era Color 1264×1680, and + // are not integer multiples of 480×272); the host queries the actual panel at + // runtime and integer-fit centers the 960×544 render on it. No analog nub; + // a REAL capacitive pointer instead of the synthesized cursor. Color panels + // (Era Color) blit RGB, grayscale panels (Verse) blit Gray8. + pocketbook: { + hostAbi: 4, + platform: "pocketbook", + form: "takeover", + display: { + physicalViewport: [960, 544], + logicalViewports: [[480, 272]], + presentations: ["integer-fit"], + rasterDensity: 2, + }, + capabilities: ["input.buttons", "input.touch", "text.glyphs.baked"], + }, + // The flat pocket-widget shell (examples/note-widget is the stock host): + // a resizable always-on-top window whose logical viewport IS the window, + // rendered at density 2 for Retina. No nub, no synthesized cursor — the + // pointer is real, text comes from the keyboard/IME, and unseen glyphs + // bake at runtime. A widget shell is not a general app frame, so it does + // not accept fixed-viewport apps (a future macos-app target would). + "macos-widget": { + hostAbi: 3, + platform: "macos", + form: "widget", + display: { + physicalViewport: [840, 1120], + logicalViewports: [[420, 560]], + dynamicViewport: { min: [240, 180], max: [4096, 4096] }, + presentations: ["native"], + rasterDensity: 2, + }, + capabilities: [ + "input.buttons", + "input.ime", + "input.pointer", + "input.text", + "host.clipboard", + "display.viewport.live", + "text.glyphs.baked", + "text.glyphs.runtime", + ], + }, }); export type PocketTargetId = TargetId; export interface PlatformContractRegistry< - C extends CapabilityRegistry = CapabilityRegistry, - T extends TargetRegistry> = TargetRegistry>, + C extends CapabilityRegistry = CapabilityRegistry, + T extends TargetRegistry> = TargetRegistry>, > { - readonly capabilities: C; - readonly targets: T; + readonly capabilities: C; + readonly targets: T; } export function definePlatformContractRegistry< - const C extends CapabilityRegistry, - const T extends TargetRegistry>, + const C extends CapabilityRegistry, + const T extends TargetRegistry>, >(capabilities: C, targets: T): PlatformContractRegistry { - return { capabilities, targets }; + return { capabilities, targets }; } export const POCKET_PLATFORM_CONTRACTS = definePlatformContractRegistry( - POCKET_CAPABILITIES, - POCKET_TARGETS, + POCKET_CAPABILITIES, + POCKET_TARGETS, ); diff --git a/hosts/pocketbook/deploy.sh b/hosts/pocketbook/deploy.sh index 2ae4f39e..4c85e809 100755 --- a/hosts/pocketbook/deploy.sh +++ b/hosts/pocketbook/deploy.sh @@ -28,33 +28,46 @@ APP_NAME="${2:-pocketjs-hero}" # --- locate the device mount point ----------------------------------------- detect_mount() { - local bases=("/run/media/${USER:-$USER}" "/media/${USER:-$USER}" "/Volumes") - for base in "${bases[@]}"; do - [ -d "$base" ] || continue - # A PocketBook exposes an `applications/` dir at its storage root. - for cand in "$base"/*; do - [ -d "$cand/applications" ] && { echo "$cand"; return; } - done - done - return 1 + local bases=("/run/media/${USER:-$USER}" "/media/${USER:-$USER}" "/Volumes") + for base in "${bases[@]}"; do + [ -d "$base" ] || continue + # A PocketBook exposes an `applications/` dir at its storage root. + for cand in "$base"/*; do + [ -d "$cand/applications" ] && { + echo "$cand" + return + } + done + done + return 1 } MOUNT="${1:-}" if [ -z "$MOUNT" ]; then - MOUNT="$(detect_mount || true)" + MOUNT="$(detect_mount || true)" fi if [ -z "$MOUNT" ] || [ ! -d "$MOUNT" ]; then - echo "error: no device mount point found. Pass it explicitly:" >&2 - echo " $0 /run/media/\$USER/PB626" >&2 - exit 1 + echo "error: no device mount point found. Pass it explicitly:" >&2 + echo " $0 /run/media/\$USER/PB626" >&2 + exit 1 fi # --- sanity-check artifacts ------------------------------------------------- -[ -f "$HOST_BIN" ] || { echo "error: host binary missing — run cargo zigbuild first ($HOST_BIN)" >&2; exit 1; } -[ -f "$BUNDLE_JS" ] || { echo "error: $BUNDLE_JS missing — run 'bun pocket compile --target pocketbook …' first" >&2; exit 1; } -[ -f "$BUNDLE_PAK" ] || { echo "error: $BUNDLE_PAK missing — run 'bun pocket compile --target pocketbook …' first" >&2; exit 1; } +[ -f "$HOST_BIN" ] || { + echo "error: host binary missing — run cargo zigbuild first ($HOST_BIN)" >&2 + exit 1 +} +[ -f "$BUNDLE_JS" ] || { + echo "error: $BUNDLE_JS missing — run 'bun pocket compile --target pocketbook …' first" >&2 + exit 1 +} +[ -f "$BUNDLE_PAK" ] || { + echo "error: $BUNDLE_PAK missing — run 'bun pocket compile --target pocketbook …' first" >&2 + exit 1 +} DEST="$MOUNT/applications/$APP_NAME" +LAUNCHER="$MOUNT/applications/$APP_NAME.app" echo "==> deploying to $DEST" mkdir -p "$DEST" @@ -63,5 +76,20 @@ cp "$BUNDLE_JS" "$DEST/app.js" cp "$BUNDLE_PAK" "$DEST/app.pak" chmod +x "$DEST/$APP_NAME" +# Create the .app launcher script (PocketBook discovers apps via *.app files). +# Logs go to /tmp/pocketjs.log so they survive until the next reboot. +cat >"$LAUNCHER" <"\$LOG" 2>&1 +EOF +chmod +x "$LAUNCHER" 2>/dev/null || true # FAT ignores exec bit; firmware runs .app via sh + echo "==> done. Eject safely, then launch '$APP_NAME' from the PocketBook launcher." echo " (If it doesn't appear, the firmware may need a rescan/restart.)" diff --git a/hosts/pocketbook/src/framebuffer.rs b/hosts/pocketbook/src/framebuffer.rs index 27074980..fc5777b1 100644 --- a/hosts/pocketbook/src/framebuffer.rs +++ b/hosts/pocketbook/src/framebuffer.rs @@ -13,8 +13,10 @@ //! writes the RGB triple directly. One blit path therefore serves both gray //! and color devices with no host-side conversion. -use inkview::screen::{RGB24, Screen}; -use pocketjs_core::{Ui, raster}; +use inkview::screen::{Screen, RGB24}; +use pocketjs_core::{raster, Ui}; + +use crate::Geometry; /// One changed tile, in render-buffer pixel coordinates. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -101,16 +103,21 @@ impl FramebufferPipeline { std::mem::swap(&mut self.rgba, &mut self.prev); } - /// Blit changed tiles to the inkview framebuffer at screen offset (ox, oy). + /// Blit changed tiles to the inkview framebuffer, scaling through `geo`. /// Issues no panel update — the caller drives that through `refresh`. - pub fn blit_dirty(&self, screen: &mut Screen, dirty: &[DirtyRect], ox: usize, oy: usize) { + pub fn blit_dirty(&self, screen: &mut Screen, dirty: &[DirtyRect], geo: &Geometry) { for r in dirty { - for y in r.y..(r.y + r.h) { - for x in r.x..(r.x + r.w) { - let i = (y * self.w + x) * 4; + let (sx_min, sy_min, sw, sh) = geo.render_rect_to_screen(r.x, r.y, r.w, r.h); + for dy in 0..sh { + let sy = sy_min + dy; + let src_y = geo.screen_to_render_y(sy); + for dx in 0..sw { + let sx = sx_min + dx; + let src_x = geo.screen_to_render_x(sx); + let i = (src_y * self.w + src_x) * 4; screen.draw( - x + ox, - y + oy, + sx, + sy, RGB24(self.rgba[i], self.rgba[i + 1], self.rgba[i + 2]), ); } @@ -118,14 +125,17 @@ impl FramebufferPipeline { } } - /// Full-buffer blit at offset (used on Show / before a full_update). - pub fn blit_all(&self, screen: &mut Screen, ox: usize, oy: usize) { - for y in 0..self.h { - for x in 0..self.w { - let i = (y * self.w + x) * 4; + /// Full-buffer blit scaled through `geo` (used on Show / before a + /// full_update). + pub fn blit_all(&self, screen: &mut Screen, geo: &Geometry) { + for sy in geo.oy..(geo.oy + geo.disp_h) { + let src_y = geo.screen_to_render_y(sy); + for sx in geo.ox..(geo.ox + geo.disp_w) { + let src_x = geo.screen_to_render_x(sx); + let i = (src_y * self.w + src_x) * 4; screen.draw( - x + ox, - y + oy, + sx, + sy, RGB24(self.rgba[i], self.rgba[i + 1], self.rgba[i + 2]), ); } @@ -151,7 +161,7 @@ mod tests { let mut fb = FramebufferPipeline::new(32, 32, 1); solid(&mut fb, 10, 20, 30); fb.advance(); // prev = current - // Same content again → no diff. + // Same content again → no diff. solid(&mut fb, 10, 20, 30); assert!(fb.diff().is_empty()); } diff --git a/hosts/pocketbook/src/input.rs b/hosts/pocketbook/src/input.rs index 272fc8b6..cf5a2deb 100644 --- a/hosts/pocketbook/src/input.rs +++ b/hosts/pocketbook/src/input.rs @@ -31,23 +31,27 @@ pub struct Input { buttons: u32, /// Current contact in LOGICAL px (None = up). PocketBook is single-touch. touch: Option<(u32, u32)>, - density: i32, ox: i32, oy: i32, logical_w: u32, logical_h: u32, + /// Displayed width on the panel (after scale-to-fit). + disp_w: u32, + /// Displayed height on the panel (after scale-to-fit). + disp_h: u32, } impl Input { - pub fn new(density: u32, ox: i32, oy: i32, logical_w: u32, logical_h: u32) -> Self { + pub fn new(ox: i32, oy: i32, logical_w: u32, logical_h: u32, disp_w: u32, disp_h: u32) -> Self { Self { buttons: 0, touch: None, - density: density as i32, ox, oy, logical_w, logical_h, + disp_w, + disp_h, } } @@ -76,9 +80,12 @@ impl Input { } /// Physical screen px → logical viewport px (≤511/axis by construction). + /// Maps through the displayed area: screen → render → logical. fn to_logical(&self, x: i32, y: i32) -> (u32, u32) { - let lx = ((x - self.ox) / self.density).clamp(0, self.logical_w as i32 - 1) as u32; - let ly = ((y - self.oy) / self.density).clamp(0, self.logical_h as i32 - 1) as u32; + let lx = ((x - self.ox).max(0) as u32 * self.logical_w / self.disp_w) + .clamp(0, self.logical_w - 1); + let ly = ((y - self.oy).max(0) as u32 * self.logical_h / self.disp_h) + .clamp(0, self.logical_h - 1); (lx, ly) } @@ -101,7 +108,7 @@ fn key_bit(key: Key) -> u32 { match key { Key::Up => BTN_UP, Key::Down => BTN_DOWN, - Key::Left | Key::Prev | Key::Prev2 => BTN_LEFT, // page-turn = prev + Key::Left | Key::Prev | Key::Prev2 => BTN_LEFT, // page-turn = prev Key::Right | Key::Next | Key::Next2 => BTN_RIGHT, // page-turn = next Key::Ok => BTN_CROSS, Key::Back => BTN_CIRCLE, @@ -127,7 +134,7 @@ mod tests { #[test] fn keys_set_and_clear_button_bits() { - let mut input = Input::new(2, 0, 0, 480, 320); + let mut input = Input::new(0, 0, 480, 320, 960, 640); assert_eq!(input.on_event_matches_key(Key::Ok), BTN_CROSS); input.apply_key_down(Key::Ok); assert_eq!(input.snapshot().0 & BTN_CROSS, BTN_CROSS); @@ -137,17 +144,32 @@ mod tests { #[test] fn pointer_maps_physical_to_logical() { - // density 2, offset (1, 0), logical 511×379. - let mut input = Input::new(2, 1, 0, 511, 379); + // No scaling: disp = logical * density = 511*2 × 379*2 = 1022×758, + // offset (1, 0), logical 511×379. + let mut input = Input::new(1, 0, 511, 379, 1022, 758); input.on_event(Event::PointerDown { x: 103, y: 41 }); let (_, _, touches) = input.snapshot(); assert_eq!(touches.len(), 1); - // logical = (physical - offset) / density = (103-1)/2=51, 41/2=20. + // logical = (physical - offset) * logical / disp = (103-1)*511/1022=51, 41*379/758=20. assert_eq!(touches[0], pack_touch(0, 51, 20)); input.on_event(Event::PointerUp { x: 103, y: 41 }); assert!(input.snapshot().2.is_empty()); } + #[test] + fn pointer_maps_with_scaling() { + // Scaled: render 960×544 on panel 758×1024 → disp 758×429, ox=0, oy=297. + // logical 480×272. + let mut input = Input::new(0, 297, 480, 272, 758, 429); + // Touch at panel (379, 512) → render (379*960/758, (512-297)*544/429) + // = (480, 272) → logical (480*480/960, 272*272/544) = (240, 136). + // But via our formula: lx = 379*480/758 = 240, ly = (512-297)*272/429 = 215*272/429 = 136. + input.on_event(Event::PointerDown { x: 379, y: 512 }); + let (_, _, touches) = input.snapshot(); + assert_eq!(touches.len(), 1); + assert_eq!(touches[0], pack_touch(0, 240, 136)); + } + // Test helpers (avoid needing to construct full Events for key tests). impl Input { fn on_event_matches_key(&self, key: Key) -> u32 { diff --git a/hosts/pocketbook/src/main.rs b/hosts/pocketbook/src/main.rs index 010f75e0..4135bac8 100644 --- a/hosts/pocketbook/src/main.rs +++ b/hosts/pocketbook/src/main.rs @@ -90,12 +90,14 @@ fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver) -> Re let geo = Geometry::for_panel(phys_w, phys_h); log::info!( - "pocketbook: panel {phys_w}x{phys_h}, logical {}x{} @{}x, render {}x{} +({},{})", + "pocketbook: panel {phys_w}x{phys_h}, logical {}x{} @{}x, render {}x{} → disp {}x{} +({},{})", geo.logical_w, geo.logical_h, geo.density, geo.render_w, geo.render_h, + geo.disp_w, + geo.disp_h, geo.ox, geo.oy ); @@ -121,11 +123,12 @@ fn run(iv: &'static inkview::bindings::Inkview, rx: mpsc::Receiver) -> Re let mut fb = framebuffer::FramebufferPipeline::new(geo.render_w, geo.render_h, geo.density); let mut refresh = refresh::Refresh::new(); let mut input = input::Input::new( - geo.density, geo.ox as i32, geo.oy as i32, geo.logical_w as u32, geo.logical_h as u32, + geo.disp_w as u32, + geo.disp_h as u32, ); // First paint: render one frame and full-update so the screen starts clean. @@ -223,11 +226,11 @@ fn tick( if full { // Full redraw (first paint / return from background): blit everything // and flash the panel once for a clean, ghost-free image. - fb.blit_all(screen, geo.ox, geo.oy); + fb.blit_all(screen, geo); refresh.full(screen); } else if !dirty.is_empty() { - fb.blit_dirty(screen, &dirty, geo.ox, geo.oy); - let screen_dirty = offset_rects(&dirty, geo.ox, geo.oy); + fb.blit_dirty(screen, &dirty, geo); + let screen_dirty = offset_rects(&dirty, geo); refresh.present(screen, &screen_dirty); } else { // No change this frame; still let the refresh policy run its quiet @@ -240,15 +243,18 @@ fn tick( Ok(()) } -/// Render-buffer rects → screen rects (integer-fit origin offset). -fn offset_rects(rects: &[DirtyRect], ox: usize, oy: usize) -> Vec { +/// Render-buffer rects → screen rects via the geometry mapping. +fn offset_rects(rects: &[DirtyRect], geo: &Geometry) -> Vec { rects .iter() - .map(|r| DirtyRect { - x: r.x + ox, - y: r.y + oy, - w: r.w, - h: r.h, + .map(|r| { + let (sx, sy, sw, sh) = geo.render_rect_to_screen(r.x, r.y, r.w, r.h); + DirtyRect { + x: sx, + y: sy, + w: sw, + h: sh, + } }) .collect() } @@ -260,33 +266,89 @@ struct Geometry { density: u32, render_w: usize, render_h: usize, + /// Displayed width on the panel after scale-to-fit (≤ render_w). + disp_w: usize, + /// Displayed height on the panel after scale-to-fit (≤ render_h). + disp_h: usize, + /// Horizontal centering offset on the panel. ox: usize, + /// Vertical centering offset on the panel. oy: usize, } impl Geometry { - /// Present the bundle's fixed 480×272 @2x surface (a 960×544 render) and - /// integer-fit center it on the actual panel. The panel size only sets the - /// centering offset; the logical viewport and density are fixed to match - /// the pocketbook target profile (and stay ≤511/axis, so touch coordinates - /// fit the 9-bit wire format). + /// Present the bundle's fixed 480×272 @2x surface (a 960×544 render) on + /// the actual panel. When the render fits, it is integer-centered. When it + /// doesn't (e.g. a 960-wide render on a 758-wide portrait panel like the + /// Verse), it is nearest-neighbor scaled down to fit and then centered. + /// The logical viewport and density are fixed to match the pocketbook + /// target profile (and stay ≤511/axis, so touch coordinates fit the 9-bit + /// wire format). fn for_panel(phys_w: usize, phys_h: usize) -> Self { let logical_w = LOGICAL_W as usize; let logical_h = LOGICAL_H as usize; let render_w = logical_w * DENSITY as usize; let render_h = logical_h * DENSITY as usize; - let ox = phys_w.saturating_sub(render_w) / 2; - let oy = phys_h.saturating_sub(render_h) / 2; + + let (disp_w, disp_h) = if render_w <= phys_w && render_h <= phys_h { + (render_w, render_h) + } else { + // Scale down to fit: pick the binding axis. + // Compare phys_w/render_w vs phys_h/render_h without floats: + // phys_w * render_h < phys_h * render_w → width binds + if phys_w * render_h < phys_h * render_w { + let dw = phys_w; + let dh = (render_h * phys_w) / render_w; + (dw, dh.max(1)) + } else { + let dh = phys_h; + let dw = (render_w * phys_h) / render_h; + (dw.max(1), dh) + } + }; + + let ox = phys_w.saturating_sub(disp_w) / 2; + let oy = phys_h.saturating_sub(disp_h) / 2; Self { logical_w, logical_h, density: DENSITY, render_w, render_h, + disp_w, + disp_h, ox, oy, } } + + /// Map a panel x coordinate back to a render-buffer x coordinate. + #[inline] + fn screen_to_render_x(&self, sx: usize) -> usize { + (sx.saturating_sub(self.ox)) * self.render_w / self.disp_w + } + + /// Map a panel y coordinate back to a render-buffer y coordinate. + #[inline] + fn screen_to_render_y(&self, sy: usize) -> usize { + (sy.saturating_sub(self.oy)) * self.render_h / self.disp_h + } + + /// Conservatively map a render-buffer rect to the screen rect that covers + /// every panel pixel sampling from within it. + fn render_rect_to_screen( + &self, + rx: usize, + ry: usize, + rw: usize, + rh: usize, + ) -> (usize, usize, usize, usize) { + let sx_min = (rx * self.disp_w).div_ceil(self.render_w) + self.ox; + let sy_min = (ry * self.disp_h).div_ceil(self.render_h) + self.oy; + let sx_max = ((rx + rw) * self.disp_w - 1) / self.render_w + self.ox; + let sy_max = ((ry + rh) * self.disp_h - 1) / self.render_h + self.oy; + (sx_min, sy_min, sx_max - sx_min + 1, sy_max - sy_min + 1) + } } fn pak_path() -> String { diff --git a/tools/pocket.ts b/tools/pocket.ts index 313cb92e..9f17bd04 100644 --- a/tools/pocket.ts +++ b/tools/pocket.ts @@ -18,31 +18,33 @@ const argv = Bun.argv.slice(2); const command = argv.shift(); function usage(message?: string): never { - if (message) console.error(`PocketJS: ${message}`); - console.error( - "usage: bun pocket --target [--manifest pocket.json] [--project-root .] [--outdir dist] [-- backend args]", - ); - process.exit(1); + if (message) console.error(`PocketJS: ${message}`); + console.error( + "usage: bun pocket --target [--manifest pocket.json] [--project-root .] [--outdir dist] [-- backend args]", + ); + process.exit(1); } function takeOption(name: string): string | undefined { - const inline = argv.findIndex((value) => value.startsWith(`--${name}=`)); - if (inline >= 0) return argv.splice(inline, 1)[0]!.slice(name.length + 3); - const index = argv.indexOf(`--${name}`); - if (index < 0) return undefined; - const value = argv[index + 1]; - if (!value || value.startsWith("--")) usage(`--${name} requires a value`); - argv.splice(index, 2); - return value; + const inline = argv.findIndex((value) => value.startsWith(`--${name}=`)); + if (inline >= 0) return argv.splice(inline, 1)[0]!.slice(name.length + 3); + const index = argv.indexOf(`--${name}`); + if (index < 0) return undefined; + const value = argv[index + 1]; + if (!value || value.startsWith("--")) usage(`--${name} requires a value`); + argv.splice(index, 2); + return value; } if (command !== "check" && command !== "compile" && command !== "build") { - usage(`unknown command ${command ?? ""}`); + usage(`unknown command ${command ?? ""}`); } const target = takeOption("target"); if (!target) usage("--target is required"); const manifestPath = resolve(takeOption("manifest") ?? "pocket.json"); -const projectRoot = resolve(takeOption("project-root") ?? dirname(manifestPath)); +const projectRoot = resolve( + takeOption("project-root") ?? dirname(manifestPath), +); const outdir = resolve(projectRoot, takeOption("outdir") ?? "dist"); // Backend args live strictly AFTER `--`; anything else left over is a typo. // (Without the separator check-first split, `argv.splice(0)` would swallow @@ -56,33 +58,37 @@ if (!existsSync(manifestPath)) usage(`manifest not found: ${manifestPath}`); const manifestInput: unknown = await Bun.file(manifestPath).json(); const resolution = validateAndResolveBuildPlan(manifestInput, { target }); if (!resolution.ok) { - for (const diagnostic of resolution.diagnostics) { - console.error(`${diagnostic.code} ${diagnostic.path || "/"}: ${diagnostic.message}`); - } - process.exit(1); + for (const diagnostic of resolution.diagnostics) { + console.error( + `${diagnostic.code} ${diagnostic.path || "/"}: ${diagnostic.message}`, + ); + } + process.exit(1); } const plan = resolution.plan; const entry = resolve(projectRoot, plan.app.entry); if (!existsSync(entry)) usage(`app entry not found: ${entry}`); const typeResult = checkAppTypes({ - entry, - tsconfigPath: existsSync(resolve(projectRoot, "tsconfig.json")) - ? resolve(projectRoot, "tsconfig.json") - : undefined, - declarationFiles: [ - resolve(frameworkRoot, "framework/src/jsx.d.ts"), - resolve(frameworkRoot, "framework/src/vue-sfc.d.ts"), - ], + entry, + tsconfigPath: existsSync(resolve(projectRoot, "tsconfig.json")) + ? resolve(projectRoot, "tsconfig.json") + : undefined, + declarationFiles: [ + resolve(frameworkRoot, "framework/src/jsx.d.ts"), + resolve(frameworkRoot, "framework/src/vue-sfc.d.ts"), + ], }); if (!typeResult.ok) { - for (const diagnostic of typeResult.diagnostics.filter((item) => item.category === "error")) { - const location = diagnostic.file - ? `${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}:${diagnostic.column}` : ""}` - : "TypeScript"; - console.error(`${location} TS${diagnostic.code}: ${diagnostic.message}`); - } - process.exit(1); + for (const diagnostic of typeResult.diagnostics.filter( + (item) => item.category === "error", + )) { + const location = diagnostic.file + ? `${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}:${diagnostic.column}` : ""}` + : "TypeScript"; + console.error(`${location} TS${diagnostic.code}: ${diagnostic.message}`); + } + process.exit(1); } console.log(`✓ pocket.json v2`); @@ -100,80 +106,84 @@ mkdirSync(planDirectory, { recursive: true }); await Bun.write(planPath, JSON.stringify(plan, null, 2) + "\n"); async function run(args: string[], label: string): Promise { - const child = Bun.spawn(args, { cwd: projectRoot, stdout: "inherit", stderr: "inherit" }); - const status = await child.exited; - if (status !== 0) throw new Error(`${label} failed with exit ${status}`); + const child = Bun.spawn(args, { + cwd: projectRoot, + stdout: "inherit", + stderr: "inherit", + }); + const status = await child.exited; + if (status !== 0) throw new Error(`${label} failed with exit ${status}`); } await run( - [ - process.execPath, - resolve(frameworkRoot, "tools/build.ts"), - `--plan=${planPath}`, - `--project-root=${projectRoot}`, - `--outdir=${outdir}`, - ], - "PocketJS compiler", + [ + process.execPath, + resolve(frameworkRoot, "tools/build.ts"), + `--plan=${planPath}`, + `--project-root=${projectRoot}`, + `--outdir=${outdir}`, + ], + "PocketJS compiler", ); if (command === "compile") process.exit(0); interface TargetBackendContext { - readonly plan: ResolvedBuildPlan; - readonly planPath: string; - readonly projectRoot: string; - readonly outdir: string; - readonly args: readonly string[]; + readonly plan: ResolvedBuildPlan; + readonly planPath: string; + readonly projectRoot: string; + readonly outdir: string; + readonly args: readonly string[]; } type TargetBackend = (context: TargetBackendContext) => Promise; const targetBackends = { - psp: async ({ planPath, projectRoot, outdir, args }) => { - await run( - [ - Bun.which("bun") ?? "bun", - resolve(frameworkRoot, "tools/psp.ts"), - `--plan=${planPath}`, - `--project-root=${projectRoot}`, - `--outdir=${outdir}`, - "--skip-build", - ...args, - ], - "PSP backend", - ); - }, - vita: async ({ planPath, projectRoot, outdir, args }) => { - await run( - [ - Bun.which("bun") ?? "bun", - resolve(frameworkRoot, "tools/vita.ts"), - `--plan=${planPath}`, - `--project-root=${projectRoot}`, - `--outdir=${outdir}`, - "--skip-build", - ...args, - ], - "Vita backend", - ); - }, - pocketbook: async ({ outdir }) => { - // The PocketBook host loads the pak + bundle from the device filesystem - // (hosts/pocketbook), so there is no platform binary to package here — - // `compile` already emitted .js + .pak into outdir. The host ELF - // is cross-compiled separately (cargo zigbuild; see hosts/pocketbook/ - // README.md). A dedicated tools/pocketbook.ts wrapper is future work. - console.log( - `✓ PocketBook bundle ready in ${outdir} — cross-compile the host ` + - `(hosts/pocketbook, cargo zigbuild) and copy both to the device.`, - ); - }, + psp: async ({ planPath, projectRoot, outdir, args }) => { + await run( + [ + Bun.which("bun") ?? "bun", + resolve(frameworkRoot, "tools/psp.ts"), + `--plan=${planPath}`, + `--project-root=${projectRoot}`, + `--outdir=${outdir}`, + "--skip-build", + ...args, + ], + "PSP backend", + ); + }, + vita: async ({ planPath, projectRoot, outdir, args }) => { + await run( + [ + Bun.which("bun") ?? "bun", + resolve(frameworkRoot, "tools/vita.ts"), + `--plan=${planPath}`, + `--project-root=${projectRoot}`, + `--outdir=${outdir}`, + "--skip-build", + ...args, + ], + "Vita backend", + ); + }, + pocketbook: async ({ outdir }) => { + // The PocketBook host loads the pak + bundle from the device filesystem + // (hosts/pocketbook), so there is no platform binary to package here — + // `compile` already emitted .js + .pak into outdir. The host ELF + // is cross-compiled separately (cargo zigbuild; see hosts/pocketbook/ + // README.md). A dedicated tools/pocketbook.ts wrapper is future work. + console.log( + `✓ PocketBook bundle ready in ${outdir} — cross-compile the host ` + + `(hosts/pocketbook, cargo zigbuild) and copy both to the device.`, + ); + }, } satisfies Record; await targetBackends[target as PocketTargetId]({ - plan, - planPath, - projectRoot, - outdir, - args: backendArgs, + plan, + planPath, + projectRoot, + outdir, + args: backendArgs, }); From efd4cd2bd5b890957692feb8d050ad01902bc905 Mon Sep 17 00:00:00 2001 From: Alberto Date: Fri, 24 Jul 2026 09:57:23 +0200 Subject: [PATCH 10/13] docs(pocketbook): record Verse on-device validation in checklist Mark boot/render, scale-to-fit centering, @2x text, grayscale output, and animated partial updates as validated on the PocketBook Verse (2026-07-24). Add a 'Validated on hardware' subsection, a Logs note pointing at the on-device pocketjs.log, and refresh the top Status paragraph. Input, idle ghosting, background-return, and color panels remain unchecked. --- hosts/pocketbook/README.md | 49 +++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/hosts/pocketbook/README.md b/hosts/pocketbook/README.md index b27a7b15..fd58bccd 100644 --- a/hosts/pocketbook/README.md +++ b/hosts/pocketbook/README.md @@ -33,8 +33,10 @@ design and the ground-truth API notes. The host cross-compiles to a stripped ARM ELF (glibc ≤2.18, dlopens `libinkview.so` at runtime), is clippy-clean, and its framebuffer/input unit tests pass. The `pocketbook` target is registered and `hero` builds for it -(`bun pocket compile --target pocketbook`). **On-device validation still needs -hardware** — see the checklist below. +(`bun pocket compile --target pocketbook`). **Boot, render, scale-to-fit +centering, and animated partial updates are validated on a PocketBook Verse** +(grayscale); input, idle ghosting, background-return, and color panels still +need a hands-on pass — see the checklist below. ## Build @@ -104,28 +106,47 @@ Run on both a grayscale and a color device to exercise both blit paths. **Boot / render** -- [ ] App appears in the launcher and opens without crashing. -- [ ] The `hero` UI renders, centered, with letterbox borders on the larger - panel. Check the startup log line (`pocketbook: panel WxH, … + (ox,oy)`) - for sane geometry. -- [ ] Text is crisp (font atlases are baked @2x). -- [ ] **Verse (gray):** image/logo render in grayscale, no color. +- [x] App appears in the launcher and opens without crashing. *(Verse)* +- [x] The `hero` UI renders, centered, with letterbox borders on the larger + panel. Check the startup log line + (`pocketbook: panel WxH, … render WxH → disp WxH +(ox,oy)`) for sane + geometry. On the Verse the 960×544 render is scaled to 758×429 and + centered vertically. *(Verse)* +- [x] Text is crisp (font atlases are baked @2x). *(Verse)* +- [x] **Verse (gray):** image/logo render in grayscale, no color. *(Verse)* - [ ] **Era Color (color):** colored UI elements actually show color. **Input** - [ ] Touch: tapping a button activates it (touch maps physical→logical via the - integer-fit offset + density). + scale-to-fit offset + displayed size). - [ ] Hardware keys: D-pad moves focus, OK activates, Back/Menu behave. - [ ] Page-turn keys (Prev/Next) map to left/right. **E-ink refresh** -- [ ] Small changes (button highlight) update without a full flash. -- [ ] During animation/scroll the panel keeps up (dynamic updates), then does a - clean partial update when it settles (~200 ms quiet). +- [x] Small changes (button highlight / spinner) update without a full flash — + the hero spinner and progress bar animate via partial updates. *(Verse)* +- [x] During animation the panel keeps up (dynamic updates), then does a clean + partial update when it settles (~200 ms quiet). *(Verse)* - [ ] No persistent ghosting after a few seconds idle (periodic cleanup works). - [ ] Returning from background (`Show`) does one clean full redraw. -**Report back** any crash (ideally with `RUST_LOG=debug` output), mis-render, -touch offset, or excessive flicker — those drive the next iteration. +### Validated on hardware + +- **PocketBook Verse** (grayscale, 758×1024) — 2026-07-24. Boot, render, + scale-to-fit centering, @2x text, and animated partial updates all confirmed + via photo + video. Input (touch / hardware keys), idle ghosting, and + background-return still need a hands-on pass. +- **Era Color / Kaleido 3** — not yet tested (color blit path unverified). + +### Logs + +The `.app` launcher redirects the host's stdout/stderr to +`applications//pocketjs.log` on the device storage (visible over USB), so +the startup geometry line and any `RUST_LOG` output survive a run. Bump the +filter with `RUST_LOG=debug` in the launcher for verbose traces. + +**Report back** any crash (ideally with the `pocketjs.log` contents), +mis-render, touch offset, or excessive flicker — those drive the next +iteration. From 093932af211a5ff28a885b2c0591be47dfd25303 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:03:12 +0800 Subject: [PATCH 11/13] refactor(pocketbook): align the host with repo conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contracts/spec/platforms.ts and tools/pocket.ts: drop the wholesale tab reformat and reapply only the pocketbook additions in house style (the diff against main is now pure additions). - tests/platform-contracts.test.ts: register pocketbook in the exhaustive target-key assertion (the full bun chain fails without it — the repo has no CI for it) and pin its capabilities/display profile. - Move the two repo-root design docs into hosts/pocketbook/docs/ (docs/STRUCTURE.md: nothing else gets a top-level name) and update references. - deploy.sh -> deploy.ts: repo scripts are Bun TypeScript; the device-side .app launcher it writes stays /bin/sh (a firmware requirement). - Pin inkview to a git rev instead of a sibling-checkout path dep so the crate builds standalone (same policy as the PSP toolchain pins). - input.rs: reuse pocketjs_core::spec::btn instead of a local BTN table. - docs/STRUCTURE.md: record pocket-ui-surface and hosts/pocketbook. Co-Authored-By: Claude Fable 5 --- contracts/spec/platforms.ts | 371 +++++++++--------- docs/STRUCTURE.md | 4 +- hosts/pocketbook/Cargo.lock | 1 + hosts/pocketbook/Cargo.toml | 14 +- hosts/pocketbook/deploy.sh | 95 ----- hosts/pocketbook/deploy.ts | 99 +++++ .../pocketbook/docs/IMPLEMENTATION.md | 2 +- .../pocketbook/docs/INTEGRATION.md | 0 hosts/pocketbook/src/input.rs | 40 +- tests/platform-contracts.test.ts | 16 +- tools/pocket.ts | 198 +++++----- 11 files changed, 410 insertions(+), 430 deletions(-) delete mode 100755 hosts/pocketbook/deploy.sh create mode 100644 hosts/pocketbook/deploy.ts rename pocketjs-inkview-implementation.md => hosts/pocketbook/docs/IMPLEMENTATION.md (99%) rename pocketjs-inkview-integration.md => hosts/pocketbook/docs/INTEGRATION.md (100%) diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index 9161f07c..9619e251 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -9,21 +9,13 @@ export type CapabilityRegistry = readonly string[]; -export function defineCapabilityRegistry( - registry: T, -): T { - return registry; +export function defineCapabilityRegistry(registry: T): T { + return registry; } export type CapabilityId = T[number]; -export const PRESENTATION_MODES = [ - "fill", - "fit", - "integer-fit", - "native", - "stretch", -] as const; +export const PRESENTATION_MODES = ["fill", "fit", "integer-fit", "native", "stretch"] as const; export type PresentationMode = (typeof PRESENTATION_MODES)[number]; export type Viewport = readonly [width: number, height: number]; @@ -43,13 +35,7 @@ export type Viewport = readonly [width: number, height: number]; * has a fixed screen (`dynamicViewport` forbidden). Apps declare viewport * variants per policy, not per target. */ -export const TARGET_FORMS = [ - "takeover", - "window", - "widget", - "kiosk", - "embedded", -] as const; +export const TARGET_FORMS = ["takeover", "window", "widget", "kiosk", "embedded"] as const; export type TargetForm = (typeof TARGET_FORMS)[number]; /** @@ -77,92 +63,90 @@ export type ExecutionClass = (typeof EXECUTION_CLASSES)[number]; export const DYNAMIC_FORMS: readonly TargetForm[] = ["window", "widget"]; export interface DisplayProfile { - readonly physicalViewport: Viewport; - readonly logicalViewports: readonly Viewport[]; - /** - * Present exactly when the target's form is dynamic (window/widget): any - * logical size within [min, max] is admissible, and the host resizes the - * core live (`display.viewport.live`). `logicalViewports` then lists the - * DEFAULT size a plan bakes assets for. `acceptsFixed` opts the target - * into hosting fixed-viewport apps in a size-locked window (an app-form - * host would set it; a widget shell is not a general app frame and - * leaves it off). - */ - readonly dynamicViewport?: { - readonly min: Viewport; - readonly max: Viewport; - readonly acceptsFixed?: boolean; - }; - readonly presentations: readonly PresentationMode[]; - /** - * Target raster samples per logical pixel for baked text, vectors, masks, - * and target-selected image variants. This is a rendering contract, not an - * API capability or a promise that presentation scale has the same value. - */ - readonly rasterDensity: number; + readonly physicalViewport: Viewport; + readonly logicalViewports: readonly Viewport[]; + /** + * Present exactly when the target's form is dynamic (window/widget): any + * logical size within [min, max] is admissible, and the host resizes the + * core live (`display.viewport.live`). `logicalViewports` then lists the + * DEFAULT size a plan bakes assets for. `acceptsFixed` opts the target + * into hosting fixed-viewport apps in a size-locked window (an app-form + * host would set it; a widget shell is not a general app frame and + * leaves it off). + */ + readonly dynamicViewport?: { + readonly min: Viewport; + readonly max: Viewport; + readonly acceptsFixed?: boolean; + }; + readonly presentations: readonly PresentationMode[]; + /** + * Target raster samples per logical pixel for baked text, vectors, masks, + * and target-selected image variants. This is a rendering contract, not an + * API capability or a promise that presentation scale has the same value. + */ + readonly rasterDensity: number; } export interface TargetProfile { - /** JS/native HostOps wire generation embedded by the selected backend. */ - readonly hostAbi: number; - /** Device/OS substrate ("psp", "vita", "macos", …). Queryable data — the - * target id is only a label. */ - readonly platform: string; - /** Shell posture (see TARGET_FORMS). */ - readonly form: TargetForm; - readonly display: DisplayProfile; - /** Framework APIs implemented and tested by this stock host. */ - readonly capabilities: readonly C[]; + /** JS/native HostOps wire generation embedded by the selected backend. */ + readonly hostAbi: number; + /** Device/OS substrate ("psp", "vita", "macos", …). Queryable data — the + * target id is only a label. */ + readonly platform: string; + /** Shell posture (see TARGET_FORMS). */ + readonly form: TargetForm; + readonly display: DisplayProfile; + /** Framework APIs implemented and tested by this stock host. */ + readonly capabilities: readonly C[]; } -export type TargetRegistry = Readonly< - Record> ->; +export type TargetRegistry = Readonly>>; export function defineTargetRegistry< - C extends string, - const T extends TargetRegistry, + C extends string, + const T extends TargetRegistry, >(registry: T): T { - return registry; + return registry; } export type TargetId = Extract; export const POCKET_CAPABILITIES = defineCapabilityRegistry([ - "input.analog.left", - "input.buttons", - // Framework-synthesized pointer for targets without a native one: the - // analog nub steers a screen cursor, hover applies `focus:`, the press - // button applies `active:` and clicks on release. Opt-in per app - // (enableCursor + pocket.json requires/enhances) — d-pad focus traversal - // remains the portable default interaction. - "input.cursor", - // OS text composition: preedit renders inline at the caret with its own - // cursor, commits arrive as ordinary text insertions, and the host docks - // the candidate window at the caret rect the app reports. Distinct from - // input.text — a host can have a keyboard without an IME. - "input.ime", - // A REAL absolute pointer (mouse/trackpad): position plus press/drag/ - // release edges, hover resolves focus. A different guarantee than - // input.cursor's synthesized nub-pointer, hence a different id (see the - // header rule). - "input.pointer", - // A hardware text stream: layout-applied characters plus named editing - // keys (Backspace/Enter/arrows/Home/End/…), key repeat included. The OSK - // is the fallback spelling on targets without it. - "input.text", - "input.touch", - // Copy/cut/paste round-trips with the OS clipboard. - "host.clipboard", - // The logical viewport is runtime-mutable: the app is told about live - // window resizes and relayouts (framework resizeViewport). Console - // targets never provide this — their viewport is a platform constant. - "display.viewport.live", - "text.glyphs.baked", - // Codepoints outside the baked charset still render: the host extends - // the font atlases at runtime (system-font rasterization + loadFontAtlas - // reload). Required by any app that accepts arbitrary text input. - "text.glyphs.runtime", + "input.analog.left", + "input.buttons", + // Framework-synthesized pointer for targets without a native one: the + // analog nub steers a screen cursor, hover applies `focus:`, the press + // button applies `active:` and clicks on release. Opt-in per app + // (enableCursor + pocket.json requires/enhances) — d-pad focus traversal + // remains the portable default interaction. + "input.cursor", + // OS text composition: preedit renders inline at the caret with its own + // cursor, commits arrive as ordinary text insertions, and the host docks + // the candidate window at the caret rect the app reports. Distinct from + // input.text — a host can have a keyboard without an IME. + "input.ime", + // A REAL absolute pointer (mouse/trackpad): position plus press/drag/ + // release edges, hover resolves focus. A different guarantee than + // input.cursor's synthesized nub-pointer, hence a different id (see the + // header rule). + "input.pointer", + // A hardware text stream: layout-applied characters plus named editing + // keys (Backspace/Enter/arrows/Home/End/…), key repeat included. The OSK + // is the fallback spelling on targets without it. + "input.text", + "input.touch", + // Copy/cut/paste round-trips with the OS clipboard. + "host.clipboard", + // The logical viewport is runtime-mutable: the app is told about live + // window resizes and relayouts (framework resizeViewport). Console + // targets never provide this — their viewport is a platform constant. + "display.viewport.live", + "text.glyphs.baked", + // Codepoints outside the baked charset still render: the host extends + // the font atlases at runtime (system-font rasterization + loadFontAtlas + // reload). Required by any app that accepts arbitrary text input. + "text.glyphs.runtime", ] as const); export type PocketCapabilityId = CapabilityId; @@ -175,121 +159,118 @@ export type PocketCapabilityId = CapabilityId; * and no tooling may parse an id — platform/form are queryable fields on the * profile. */ -export const POCKET_TARGETS = defineTargetRegistry< - PocketCapabilityId, - { - readonly psp: TargetProfile; - readonly vita: TargetProfile; - readonly pocketbook: TargetProfile; - readonly "macos-widget": TargetProfile; - } ->({ - psp: { - hostAbi: 1, - platform: "psp", - form: "takeover", - display: { - physicalViewport: [480, 272], - logicalViewports: [[480, 272]], - // integer-fit at scale 1 is the portable spelling of the native PSP - // surface and can be satisfied unchanged by higher-resolution hosts. - presentations: ["native", "integer-fit"], - rasterDensity: 1, - }, - capabilities: [ - "input.analog.left", - "input.buttons", - "input.cursor", - "text.glyphs.baked", - ], - }, - vita: { - hostAbi: 2, - platform: "vita", - form: "takeover", - display: { - physicalViewport: [960, 544], - logicalViewports: [[480, 272]], - presentations: ["integer-fit"], - rasterDensity: 2, - }, - capabilities: [ - "input.analog.left", - "input.buttons", - "input.cursor", - "input.touch", - "text.glyphs.baked", - ], - }, - // PocketBook e-readers (inkview): hosts/pocketbook reuses the backend- - // agnostic ui surface + the core software rasterizer. Same logical viewport - // and density as vita (480×272 @2x → a 960×544 render). physicalViewport is - // that nominal 2x surface — NOT the raw panel — so integer-fit apps validate - // (real panels vary by model, e.g. Verse 1024×758, Era Color 1264×1680, and - // are not integer multiples of 480×272); the host queries the actual panel at - // runtime and integer-fit centers the 960×544 render on it. No analog nub; - // a REAL capacitive pointer instead of the synthesized cursor. Color panels - // (Era Color) blit RGB, grayscale panels (Verse) blit Gray8. - pocketbook: { - hostAbi: 4, - platform: "pocketbook", - form: "takeover", - display: { - physicalViewport: [960, 544], - logicalViewports: [[480, 272]], - presentations: ["integer-fit"], - rasterDensity: 2, - }, - capabilities: ["input.buttons", "input.touch", "text.glyphs.baked"], - }, - // The flat pocket-widget shell (examples/note-widget is the stock host): - // a resizable always-on-top window whose logical viewport IS the window, - // rendered at density 2 for Retina. No nub, no synthesized cursor — the - // pointer is real, text comes from the keyboard/IME, and unseen glyphs - // bake at runtime. A widget shell is not a general app frame, so it does - // not accept fixed-viewport apps (a future macos-app target would). - "macos-widget": { - hostAbi: 3, - platform: "macos", - form: "widget", - display: { - physicalViewport: [840, 1120], - logicalViewports: [[420, 560]], - dynamicViewport: { min: [240, 180], max: [4096, 4096] }, - presentations: ["native"], - rasterDensity: 2, - }, - capabilities: [ - "input.buttons", - "input.ime", - "input.pointer", - "input.text", - "host.clipboard", - "display.viewport.live", - "text.glyphs.baked", - "text.glyphs.runtime", - ], - }, +export const POCKET_TARGETS = defineTargetRegistry; + readonly vita: TargetProfile; + readonly pocketbook: TargetProfile; + readonly "macos-widget": TargetProfile; +}>({ + psp: { + hostAbi: 1, + platform: "psp", + form: "takeover", + display: { + physicalViewport: [480, 272], + logicalViewports: [[480, 272]], + // integer-fit at scale 1 is the portable spelling of the native PSP + // surface and can be satisfied unchanged by higher-resolution hosts. + presentations: ["native", "integer-fit"], + rasterDensity: 1, + }, + capabilities: [ + "input.analog.left", + "input.buttons", + "input.cursor", + "text.glyphs.baked", + ], + }, + vita: { + hostAbi: 2, + platform: "vita", + form: "takeover", + display: { + physicalViewport: [960, 544], + logicalViewports: [[480, 272]], + presentations: ["integer-fit"], + rasterDensity: 2, + }, + capabilities: [ + "input.analog.left", + "input.buttons", + "input.cursor", + "input.touch", + "text.glyphs.baked", + ], + }, + // PocketBook e-readers (inkview): hosts/pocketbook reuses the backend- + // agnostic ui surface + the core software rasterizer. Same logical viewport + // and density as vita (480×272 @2x → a 960×544 render). physicalViewport is + // that nominal 2x surface — NOT the raw panel — so integer-fit apps validate + // (real panels vary by model, e.g. Verse 1024×758, Era Color 1264×1680, and + // are not integer multiples of 480×272); the host queries the actual panel at + // runtime and integer-fit centers the 960×544 render on it. No analog nub; + // a REAL capacitive pointer instead of the synthesized cursor. Color panels + // (Era Color) blit RGB, grayscale panels (Verse) blit Gray8. + pocketbook: { + hostAbi: 4, + platform: "pocketbook", + form: "takeover", + display: { + physicalViewport: [960, 544], + logicalViewports: [[480, 272]], + presentations: ["integer-fit"], + rasterDensity: 2, + }, + capabilities: ["input.buttons", "input.touch", "text.glyphs.baked"], + }, + // The flat pocket-widget shell (examples/note-widget is the stock host): + // a resizable always-on-top window whose logical viewport IS the window, + // rendered at density 2 for Retina. No nub, no synthesized cursor — the + // pointer is real, text comes from the keyboard/IME, and unseen glyphs + // bake at runtime. A widget shell is not a general app frame, so it does + // not accept fixed-viewport apps (a future macos-app target would). + "macos-widget": { + hostAbi: 3, + platform: "macos", + form: "widget", + display: { + physicalViewport: [840, 1120], + logicalViewports: [[420, 560]], + dynamicViewport: { min: [240, 180], max: [4096, 4096] }, + presentations: ["native"], + rasterDensity: 2, + }, + capabilities: [ + "input.buttons", + "input.ime", + "input.pointer", + "input.text", + "host.clipboard", + "display.viewport.live", + "text.glyphs.baked", + "text.glyphs.runtime", + ], + }, }); export type PocketTargetId = TargetId; export interface PlatformContractRegistry< - C extends CapabilityRegistry = CapabilityRegistry, - T extends TargetRegistry> = TargetRegistry>, + C extends CapabilityRegistry = CapabilityRegistry, + T extends TargetRegistry> = TargetRegistry>, > { - readonly capabilities: C; - readonly targets: T; + readonly capabilities: C; + readonly targets: T; } export function definePlatformContractRegistry< - const C extends CapabilityRegistry, - const T extends TargetRegistry>, + const C extends CapabilityRegistry, + const T extends TargetRegistry>, >(capabilities: C, targets: T): PlatformContractRegistry { - return { capabilities, targets }; + return { capabilities, targets }; } export const POCKET_PLATFORM_CONTRACTS = definePlatformContractRegistry( - POCKET_CAPABILITIES, - POCKET_TARGETS, + POCKET_CAPABILITIES, + POCKET_TARGETS, ); diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 23914cae..6e8c8c06 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -12,7 +12,7 @@ pocketjs/ │ ├─ backends/ platform render backends (ESP32-P4 PPA is a standalone no_std crate) │ ├─ wasm/ core compiled to wasm32 for web/sim hosts (standalone crate) │ ├─ pocket3d/ the 3D core family (bsp, cook, gu, vita) + desktop examples -│ ├─ crates/ non-3D engine crates: pocket-mod, pocket-ui-wgpu, pocket-vrm, pocket-widget +│ ├─ crates/ non-3D engine crates: pocket-mod, pocket-ui-surface, pocket-ui-wgpu, pocket-vrm, pocket-widget │ └─ Cargo.toml the desktop workspace root (core/, wasm/ and the console- │ toolchain crates are deliberately excluded and standalone — │ cargo-psp/vitasdk need lone crates; see engine/core/Cargo.toml) @@ -20,6 +20,7 @@ pocketjs/ │ ├─ psp/ QuickJS + rust-psp EBOOT host │ ├─ vita/ Vita host │ ├─ esp32p4/ reusable ESP-IDF PPA adapter + component smoke build +│ ├─ pocketbook/ PocketBook e-reader host (inkview, standalone lone-bin crate) │ ├─ web/ browser dev host (wasm core) │ └─ sim/ deterministic headless simulation host (docs/DETERMINISM.md) ├─ framework/ Guest: @pocketjs/framework @@ -63,6 +64,7 @@ New things go where the axis says — never invent a top-level directory: change; the `exports`/`files` maps in package.json absorb internal moves. - **Cargo stays non-workspace where toolchains demand it**: `engine/core`, `engine/wasm`, `engine/backends/esp32p4-ppa`, `hosts/psp`, `hosts/vita`, + `hosts/pocketbook`, and the gu/vita 3D crates each stand alone with their own lockfiles. `engine/Cargo.toml` is the one desktop workspace. - **Moves are `git mv`** — history stays traceable. diff --git a/hosts/pocketbook/Cargo.lock b/hosts/pocketbook/Cargo.lock index 93c03e12..ed3ac59f 100644 --- a/hosts/pocketbook/Cargo.lock +++ b/hosts/pocketbook/Cargo.lock @@ -294,6 +294,7 @@ dependencies = [ [[package]] name = "inkview" version = "0.3.0" +source = "git+https://github.com/simmsb/inkview-rs?rev=0e4ecab93f6e2907c255bb50bc4b6c1882779274#0e4ecab93f6e2907c255bb50bc4b6c1882779274" dependencies = [ "libloading", "num-derive", diff --git a/hosts/pocketbook/Cargo.toml b/hosts/pocketbook/Cargo.toml index 12a6c718..f4043956 100644 --- a/hosts/pocketbook/Cargo.toml +++ b/hosts/pocketbook/Cargo.toml @@ -5,7 +5,7 @@ # crates. It reuses the backend-agnostic `ui` surface (pocket-ui-surface) and # the core's software rasterizer, then blits the frame as RGB24 (inkview # converts to gray on grayscale panels, writes RGB on color panels). -# See pocketjs-inkview-implementation.md at the repo root. +# See docs/IMPLEMENTATION.md in this directory. # # Cross-compile for PocketBook's ARM Linux (glibc 2.23): # rustup target add armv7-unknown-linux-gnueabi @@ -25,12 +25,12 @@ pocketjs-core = { path = "../../engine/core", features = ["std"] } pocket-mod = { path = "../../engine/crates/pocket-mod" } pocket-ui-surface = { path = "../../engine/crates/pocket-ui-surface" } -# inkview is consumed as-is (no modifications). During development this points -# at a local checkout; for an upstream/CI build switch to the git dependency: -# inkview = { git = "https://github.com/simmsb/inkview-rs", package = "inkview" } -# Default feature is sdk-6-10; override for older firmware, e.g. -# --no-default-features --features sdk-6-5 -inkview = { path = "../../../inkview-rs/inkview" } +# inkview is consumed as-is (no modifications), pinned to a git REV like the +# PSP toolchain pins (local sibling-checkout edits are invisible until merged +# upstream and repinned). Default feature is sdk-6-10; override for older +# firmware, e.g. --no-default-features --features sdk-6-5. For local inkview +# hacking use a [patch] section, not a path rewrite here. +inkview = { git = "https://github.com/simmsb/inkview-rs", rev = "0e4ecab93f6e2907c255bb50bc4b6c1882779274", package = "inkview" } anyhow = "1" log = "0.4" diff --git a/hosts/pocketbook/deploy.sh b/hosts/pocketbook/deploy.sh deleted file mode 100755 index 4c85e809..00000000 --- a/hosts/pocketbook/deploy.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -# Deploy the PocketBook host + a demo app to a device mounted over USB. -# -# Usage: -# hosts/pocketbook/deploy.sh [MOUNT_POINT] [APP_NAME] -# -# MOUNT_POINT device mount root (default: auto-detect the first PocketBook -# under /run/media/$USER, /media/$USER, or /Volumes on macOS) -# APP_NAME launcher folder name (default: pocketjs-hero) -# -# Prereqs (run from the repo root): -# # 1. cross-compile the host -# (cd hosts/pocketbook && cargo zigbuild --release --target armv7-unknown-linux-gnueabi.2.23) -# # 2. build the app bundle for the pocketbook target -# bun pocket compile --target pocketbook --manifest apps/hero/pocket.json --project-root . -# -# The host reads app.js + app.pak from its working directory (override with -# POCKET_JS / POCKET_PAK), so we install them next to the binary. - -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -HOST_BIN="$ROOT/hosts/pocketbook/target/armv7-unknown-linux-gnueabi/release/pocketbook-host" -BUNDLE_JS="$ROOT/dist/hero-main.js" -BUNDLE_PAK="$ROOT/dist/hero-main.pak" - -APP_NAME="${2:-pocketjs-hero}" - -# --- locate the device mount point ----------------------------------------- -detect_mount() { - local bases=("/run/media/${USER:-$USER}" "/media/${USER:-$USER}" "/Volumes") - for base in "${bases[@]}"; do - [ -d "$base" ] || continue - # A PocketBook exposes an `applications/` dir at its storage root. - for cand in "$base"/*; do - [ -d "$cand/applications" ] && { - echo "$cand" - return - } - done - done - return 1 -} - -MOUNT="${1:-}" -if [ -z "$MOUNT" ]; then - MOUNT="$(detect_mount || true)" -fi -if [ -z "$MOUNT" ] || [ ! -d "$MOUNT" ]; then - echo "error: no device mount point found. Pass it explicitly:" >&2 - echo " $0 /run/media/\$USER/PB626" >&2 - exit 1 -fi - -# --- sanity-check artifacts ------------------------------------------------- -[ -f "$HOST_BIN" ] || { - echo "error: host binary missing — run cargo zigbuild first ($HOST_BIN)" >&2 - exit 1 -} -[ -f "$BUNDLE_JS" ] || { - echo "error: $BUNDLE_JS missing — run 'bun pocket compile --target pocketbook …' first" >&2 - exit 1 -} -[ -f "$BUNDLE_PAK" ] || { - echo "error: $BUNDLE_PAK missing — run 'bun pocket compile --target pocketbook …' first" >&2 - exit 1 -} - -DEST="$MOUNT/applications/$APP_NAME" -LAUNCHER="$MOUNT/applications/$APP_NAME.app" -echo "==> deploying to $DEST" -mkdir -p "$DEST" - -cp "$HOST_BIN" "$DEST/$APP_NAME" -cp "$BUNDLE_JS" "$DEST/app.js" -cp "$BUNDLE_PAK" "$DEST/app.pak" -chmod +x "$DEST/$APP_NAME" - -# Create the .app launcher script (PocketBook discovers apps via *.app files). -# Logs go to /tmp/pocketjs.log so they survive until the next reboot. -cat >"$LAUNCHER" <"\$LOG" 2>&1 -EOF -chmod +x "$LAUNCHER" 2>/dev/null || true # FAT ignores exec bit; firmware runs .app via sh - -echo "==> done. Eject safely, then launch '$APP_NAME' from the PocketBook launcher." -echo " (If it doesn't appear, the firmware may need a rescan/restart.)" diff --git a/hosts/pocketbook/deploy.ts b/hosts/pocketbook/deploy.ts new file mode 100644 index 00000000..46c75857 --- /dev/null +++ b/hosts/pocketbook/deploy.ts @@ -0,0 +1,99 @@ +// Deploy the PocketBook host + a demo app to a device mounted over USB. +// +// Usage: +// bun hosts/pocketbook/deploy.ts [MOUNT_POINT] [APP_NAME] +// +// MOUNT_POINT device mount root (default: auto-detect the first PocketBook +// under /run/media/$USER, /media/$USER, or /Volumes on macOS) +// APP_NAME launcher folder name (default: pocketjs-hero) +// +// Prereqs (run from the repo root): +// # 1. cross-compile the host +// (cd hosts/pocketbook && cargo zigbuild --release --target armv7-unknown-linux-gnueabi.2.23) +// # 2. build the app bundle for the pocketbook target +// bun pocket compile --target pocketbook --manifest apps/hero/pocket.json --project-root . +// +// The host reads app.js + app.pak from its working directory (override with +// POCKET_JS / POCKET_PAK), so we install them next to the binary. + +import { chmodSync, existsSync, mkdirSync, readdirSync } from "node:fs"; +import { copyFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +const ROOT = resolve(dirname(new URL(import.meta.url).pathname), "../.."); +const HOST_BIN = join( + ROOT, + "hosts/pocketbook/target/armv7-unknown-linux-gnueabi/release/pocketbook-host", +); +const BUNDLE_JS = join(ROOT, "dist/hero-main.js"); +const BUNDLE_PAK = join(ROOT, "dist/hero-main.pak"); + +const [mountArg, appArg] = Bun.argv.slice(2); +const APP_NAME = appArg ?? "pocketjs-hero"; + +// --- locate the device mount point ----------------------------------------- +// A PocketBook exposes an `applications/` dir at its storage root. +function detectMount(): string | undefined { + const user = process.env.USER ?? ""; + const bases = [`/run/media/${user}`, `/media/${user}`, "/Volumes"]; + for (const base of bases) { + if (!existsSync(base)) continue; + for (const entry of readdirSync(base)) { + const candidate = join(base, entry); + if (existsSync(join(candidate, "applications"))) return candidate; + } + } + return undefined; +} + +const MOUNT = mountArg || detectMount(); +if (!MOUNT || !existsSync(MOUNT)) { + console.error("error: no device mount point found. Pass it explicitly:"); + console.error(" bun hosts/pocketbook/deploy.ts /run/media/$USER/PB626"); + process.exit(1); +} + +// --- sanity-check artifacts ------------------------------------------------- +if (!existsSync(HOST_BIN)) { + console.error(`error: host binary missing — run cargo zigbuild first (${HOST_BIN})`); + process.exit(1); +} +for (const bundle of [BUNDLE_JS, BUNDLE_PAK]) { + if (!existsSync(bundle)) { + console.error(`error: ${bundle} missing — run 'bun pocket compile --target pocketbook …' first`); + process.exit(1); + } +} + +const DEST = join(MOUNT, "applications", APP_NAME); +const LAUNCHER = join(MOUNT, "applications", `${APP_NAME}.app`); +console.log(`==> deploying to ${DEST}`); +mkdirSync(DEST, { recursive: true }); + +await copyFile(HOST_BIN, join(DEST, APP_NAME)); +await copyFile(BUNDLE_JS, join(DEST, "app.js")); +await copyFile(BUNDLE_PAK, join(DEST, "app.pak")); +chmodSync(join(DEST, APP_NAME), 0o755); + +// The .app launcher (PocketBook firmware discovers apps via *.app files and +// runs them through sh — the launcher itself must stay a device-side shell +// script). Logs land next to the binary so they survive over USB. +await Bun.write( + LAUNCHER, + `#!/bin/sh +# PocketJS ${APP_NAME} launcher for PocketBook + +APP_DIR="/mnt/ext1/applications/${APP_NAME}" +LOG="\${APP_DIR}/pocketjs.log" + +cd "\${APP_DIR}" || exit 1 +export RUST_LOG="\${RUST_LOG:-info}" +exec ./${APP_NAME} >"\$LOG" 2>&1 +`, +); +try { + chmodSync(LAUNCHER, 0o755); // FAT ignores the exec bit; firmware runs .app via sh +} catch {} + +console.log(`==> done. Eject safely, then launch '${APP_NAME}' from the PocketBook launcher.`); +console.log(" (If it doesn't appear, the firmware may need a rescan/restart.)"); diff --git a/pocketjs-inkview-implementation.md b/hosts/pocketbook/docs/IMPLEMENTATION.md similarity index 99% rename from pocketjs-inkview-implementation.md rename to hosts/pocketbook/docs/IMPLEMENTATION.md index f2dd4b22..a22f8129 100644 --- a/pocketjs-inkview-implementation.md +++ b/hosts/pocketbook/docs/IMPLEMENTATION.md @@ -1,6 +1,6 @@ # PocketJS × inkview — Implementation File -> **Companion to** `pocketjs-inkview-integration.md` (the feasibility study). +> **Companion to** `INTEGRATION.md` (the feasibility study). > This file turns that study into a concrete, buildable plan grounded in the > **actual** APIs of `pocketjs` (this repo) and `inkview-rs` > (`/var/home/alberto/projects/inkview-rs`). diff --git a/pocketjs-inkview-integration.md b/hosts/pocketbook/docs/INTEGRATION.md similarity index 100% rename from pocketjs-inkview-integration.md rename to hosts/pocketbook/docs/INTEGRATION.md diff --git a/hosts/pocketbook/src/input.rs b/hosts/pocketbook/src/input.rs index cf5a2deb..9cfb9872 100644 --- a/hosts/pocketbook/src/input.rs +++ b/hosts/pocketbook/src/input.rs @@ -5,19 +5,7 @@ //! (`(id<<18)|(y<<9)|x`, framework/src/touch.ts) in LOGICAL viewport pixels. use inkview::event::{Event, Key}; -use pocketjs_core::spec::ANALOG_CENTER; - -// spec BTN bits (contracts/spec/spec.ts; mirrored by uihost). -const BTN_SELECT: u32 = 0x0001; -const BTN_START: u32 = 0x0008; -const BTN_UP: u32 = 0x0010; -const BTN_RIGHT: u32 = 0x0020; -const BTN_DOWN: u32 = 0x0040; -const BTN_LEFT: u32 = 0x0080; -const BTN_LTRIGGER: u32 = 0x0100; -const BTN_RTRIGGER: u32 = 0x0200; -const BTN_CIRCLE: u32 = 0x2000; -const BTN_CROSS: u32 = 0x4000; +use pocketjs_core::spec::{btn, ANALOG_CENTER}; /// What the render loop should do after handling an event. pub enum Outcome { @@ -106,16 +94,16 @@ fn pack_touch(id: u32, x: u32, y: u32) -> u32 { fn key_bit(key: Key) -> u32 { match key { - Key::Up => BTN_UP, - Key::Down => BTN_DOWN, - Key::Left | Key::Prev | Key::Prev2 => BTN_LEFT, // page-turn = prev - Key::Right | Key::Next | Key::Next2 => BTN_RIGHT, // page-turn = next - Key::Ok => BTN_CROSS, - Key::Back => BTN_CIRCLE, - Key::Menu => BTN_START, - Key::Home => BTN_SELECT, - Key::Plus => BTN_RTRIGGER, - Key::Minus => BTN_LTRIGGER, + Key::Up => btn::UP, + Key::Down => btn::DOWN, + Key::Left | Key::Prev | Key::Prev2 => btn::LEFT, // page-turn = prev + Key::Right | Key::Next | Key::Next2 => btn::RIGHT, // page-turn = next + Key::Ok => btn::CROSS, + Key::Back => btn::CIRCLE, + Key::Menu => btn::START, + Key::Home => btn::SELECT, + Key::Plus => btn::RTRIGGER, + Key::Minus => btn::LTRIGGER, _ => 0, } } @@ -135,11 +123,11 @@ mod tests { #[test] fn keys_set_and_clear_button_bits() { let mut input = Input::new(0, 0, 480, 320, 960, 640); - assert_eq!(input.on_event_matches_key(Key::Ok), BTN_CROSS); + assert_eq!(input.on_event_matches_key(Key::Ok), btn::CROSS); input.apply_key_down(Key::Ok); - assert_eq!(input.snapshot().0 & BTN_CROSS, BTN_CROSS); + assert_eq!(input.snapshot().0 & btn::CROSS, btn::CROSS); input.apply_key_up(Key::Ok); - assert_eq!(input.snapshot().0 & BTN_CROSS, 0); + assert_eq!(input.snapshot().0 & btn::CROSS, 0); } #[test] diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index b6f3ab71..2b6aebee 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -141,7 +141,7 @@ describe("pocket.json v2 schema", () => { describe("platform registry", () => { test("production advertises only the truthful stock-host profiles", () => { - expect(Object.keys(POCKET_TARGETS)).toEqual(["psp", "vita", "macos-widget"]); + expect(Object.keys(POCKET_TARGETS)).toEqual(["psp", "vita", "pocketbook", "macos-widget"]); expect(validatePlatformContractRegistry(POCKET_PLATFORM_CONTRACTS)).toEqual([]); expect(POCKET_TARGETS.psp.capabilities).toEqual([ "input.analog.left", @@ -162,6 +162,20 @@ describe("platform registry", () => { presentations: ["integer-fit"], rasterDensity: 2, }); + // The PocketBook e-reader target: real touch, no nub/cursor, and the + // same nominal 960×544 @2x surface as vita (the host integer-fits it + // onto whatever panel the device actually has). + expect(POCKET_TARGETS.pocketbook.capabilities).toEqual([ + "input.buttons", + "input.touch", + "text.glyphs.baked", + ]); + expect(POCKET_TARGETS.pocketbook.display).toEqual({ + physicalViewport: [960, 544], + logicalViewports: [[480, 272]], + presentations: ["integer-fit"], + rasterDensity: 2, + }); // The desktop widget target: dynamic viewport, real pointer/text/IME, // runtime glyph baking — and honestly NO nub or synthesized cursor. expect(POCKET_TARGETS["macos-widget"].capabilities).toEqual([ diff --git a/tools/pocket.ts b/tools/pocket.ts index 9f17bd04..313cb92e 100644 --- a/tools/pocket.ts +++ b/tools/pocket.ts @@ -18,33 +18,31 @@ const argv = Bun.argv.slice(2); const command = argv.shift(); function usage(message?: string): never { - if (message) console.error(`PocketJS: ${message}`); - console.error( - "usage: bun pocket --target [--manifest pocket.json] [--project-root .] [--outdir dist] [-- backend args]", - ); - process.exit(1); + if (message) console.error(`PocketJS: ${message}`); + console.error( + "usage: bun pocket --target [--manifest pocket.json] [--project-root .] [--outdir dist] [-- backend args]", + ); + process.exit(1); } function takeOption(name: string): string | undefined { - const inline = argv.findIndex((value) => value.startsWith(`--${name}=`)); - if (inline >= 0) return argv.splice(inline, 1)[0]!.slice(name.length + 3); - const index = argv.indexOf(`--${name}`); - if (index < 0) return undefined; - const value = argv[index + 1]; - if (!value || value.startsWith("--")) usage(`--${name} requires a value`); - argv.splice(index, 2); - return value; + const inline = argv.findIndex((value) => value.startsWith(`--${name}=`)); + if (inline >= 0) return argv.splice(inline, 1)[0]!.slice(name.length + 3); + const index = argv.indexOf(`--${name}`); + if (index < 0) return undefined; + const value = argv[index + 1]; + if (!value || value.startsWith("--")) usage(`--${name} requires a value`); + argv.splice(index, 2); + return value; } if (command !== "check" && command !== "compile" && command !== "build") { - usage(`unknown command ${command ?? ""}`); + usage(`unknown command ${command ?? ""}`); } const target = takeOption("target"); if (!target) usage("--target is required"); const manifestPath = resolve(takeOption("manifest") ?? "pocket.json"); -const projectRoot = resolve( - takeOption("project-root") ?? dirname(manifestPath), -); +const projectRoot = resolve(takeOption("project-root") ?? dirname(manifestPath)); const outdir = resolve(projectRoot, takeOption("outdir") ?? "dist"); // Backend args live strictly AFTER `--`; anything else left over is a typo. // (Without the separator check-first split, `argv.splice(0)` would swallow @@ -58,37 +56,33 @@ if (!existsSync(manifestPath)) usage(`manifest not found: ${manifestPath}`); const manifestInput: unknown = await Bun.file(manifestPath).json(); const resolution = validateAndResolveBuildPlan(manifestInput, { target }); if (!resolution.ok) { - for (const diagnostic of resolution.diagnostics) { - console.error( - `${diagnostic.code} ${diagnostic.path || "/"}: ${diagnostic.message}`, - ); - } - process.exit(1); + for (const diagnostic of resolution.diagnostics) { + console.error(`${diagnostic.code} ${diagnostic.path || "/"}: ${diagnostic.message}`); + } + process.exit(1); } const plan = resolution.plan; const entry = resolve(projectRoot, plan.app.entry); if (!existsSync(entry)) usage(`app entry not found: ${entry}`); const typeResult = checkAppTypes({ - entry, - tsconfigPath: existsSync(resolve(projectRoot, "tsconfig.json")) - ? resolve(projectRoot, "tsconfig.json") - : undefined, - declarationFiles: [ - resolve(frameworkRoot, "framework/src/jsx.d.ts"), - resolve(frameworkRoot, "framework/src/vue-sfc.d.ts"), - ], + entry, + tsconfigPath: existsSync(resolve(projectRoot, "tsconfig.json")) + ? resolve(projectRoot, "tsconfig.json") + : undefined, + declarationFiles: [ + resolve(frameworkRoot, "framework/src/jsx.d.ts"), + resolve(frameworkRoot, "framework/src/vue-sfc.d.ts"), + ], }); if (!typeResult.ok) { - for (const diagnostic of typeResult.diagnostics.filter( - (item) => item.category === "error", - )) { - const location = diagnostic.file - ? `${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}:${diagnostic.column}` : ""}` - : "TypeScript"; - console.error(`${location} TS${diagnostic.code}: ${diagnostic.message}`); - } - process.exit(1); + for (const diagnostic of typeResult.diagnostics.filter((item) => item.category === "error")) { + const location = diagnostic.file + ? `${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}:${diagnostic.column}` : ""}` + : "TypeScript"; + console.error(`${location} TS${diagnostic.code}: ${diagnostic.message}`); + } + process.exit(1); } console.log(`✓ pocket.json v2`); @@ -106,84 +100,80 @@ mkdirSync(planDirectory, { recursive: true }); await Bun.write(planPath, JSON.stringify(plan, null, 2) + "\n"); async function run(args: string[], label: string): Promise { - const child = Bun.spawn(args, { - cwd: projectRoot, - stdout: "inherit", - stderr: "inherit", - }); - const status = await child.exited; - if (status !== 0) throw new Error(`${label} failed with exit ${status}`); + const child = Bun.spawn(args, { cwd: projectRoot, stdout: "inherit", stderr: "inherit" }); + const status = await child.exited; + if (status !== 0) throw new Error(`${label} failed with exit ${status}`); } await run( - [ - process.execPath, - resolve(frameworkRoot, "tools/build.ts"), - `--plan=${planPath}`, - `--project-root=${projectRoot}`, - `--outdir=${outdir}`, - ], - "PocketJS compiler", + [ + process.execPath, + resolve(frameworkRoot, "tools/build.ts"), + `--plan=${planPath}`, + `--project-root=${projectRoot}`, + `--outdir=${outdir}`, + ], + "PocketJS compiler", ); if (command === "compile") process.exit(0); interface TargetBackendContext { - readonly plan: ResolvedBuildPlan; - readonly planPath: string; - readonly projectRoot: string; - readonly outdir: string; - readonly args: readonly string[]; + readonly plan: ResolvedBuildPlan; + readonly planPath: string; + readonly projectRoot: string; + readonly outdir: string; + readonly args: readonly string[]; } type TargetBackend = (context: TargetBackendContext) => Promise; const targetBackends = { - psp: async ({ planPath, projectRoot, outdir, args }) => { - await run( - [ - Bun.which("bun") ?? "bun", - resolve(frameworkRoot, "tools/psp.ts"), - `--plan=${planPath}`, - `--project-root=${projectRoot}`, - `--outdir=${outdir}`, - "--skip-build", - ...args, - ], - "PSP backend", - ); - }, - vita: async ({ planPath, projectRoot, outdir, args }) => { - await run( - [ - Bun.which("bun") ?? "bun", - resolve(frameworkRoot, "tools/vita.ts"), - `--plan=${planPath}`, - `--project-root=${projectRoot}`, - `--outdir=${outdir}`, - "--skip-build", - ...args, - ], - "Vita backend", - ); - }, - pocketbook: async ({ outdir }) => { - // The PocketBook host loads the pak + bundle from the device filesystem - // (hosts/pocketbook), so there is no platform binary to package here — - // `compile` already emitted .js + .pak into outdir. The host ELF - // is cross-compiled separately (cargo zigbuild; see hosts/pocketbook/ - // README.md). A dedicated tools/pocketbook.ts wrapper is future work. - console.log( - `✓ PocketBook bundle ready in ${outdir} — cross-compile the host ` + - `(hosts/pocketbook, cargo zigbuild) and copy both to the device.`, - ); - }, + psp: async ({ planPath, projectRoot, outdir, args }) => { + await run( + [ + Bun.which("bun") ?? "bun", + resolve(frameworkRoot, "tools/psp.ts"), + `--plan=${planPath}`, + `--project-root=${projectRoot}`, + `--outdir=${outdir}`, + "--skip-build", + ...args, + ], + "PSP backend", + ); + }, + vita: async ({ planPath, projectRoot, outdir, args }) => { + await run( + [ + Bun.which("bun") ?? "bun", + resolve(frameworkRoot, "tools/vita.ts"), + `--plan=${planPath}`, + `--project-root=${projectRoot}`, + `--outdir=${outdir}`, + "--skip-build", + ...args, + ], + "Vita backend", + ); + }, + pocketbook: async ({ outdir }) => { + // The PocketBook host loads the pak + bundle from the device filesystem + // (hosts/pocketbook), so there is no platform binary to package here — + // `compile` already emitted .js + .pak into outdir. The host ELF + // is cross-compiled separately (cargo zigbuild; see hosts/pocketbook/ + // README.md). A dedicated tools/pocketbook.ts wrapper is future work. + console.log( + `✓ PocketBook bundle ready in ${outdir} — cross-compile the host ` + + `(hosts/pocketbook, cargo zigbuild) and copy both to the device.`, + ); + }, } satisfies Record; await targetBackends[target as PocketTargetId]({ - plan, - planPath, - projectRoot, - outdir, - args: backendArgs, + plan, + planPath, + projectRoot, + outdir, + args: backendArgs, }); From e720e98ef3323c534f3ec2ab0f19913437feb073 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:04:06 +0800 Subject: [PATCH 12/13] feat(pocketbook): rasterize incrementally via core DrawList damage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose the two damage layers so each does what the other cannot: - raster::render_scaled_incremental + a core DamageTracker repaint only the DrawList-diffed regions of a persistent RGBA8 target — an idle frame plans, rasterizes, and scans nothing; - the 16x16 pixel tile diff now runs only INSIDE the damage regions and still owns the e-ink refresh decision: conservative DrawList damage that renders identical pixels flashes nothing. The retained buffer always holds the complete current frame, so the Show/full path re-blits without re-rasterizing; prev latches only the blitted tiles. Pixel parity with full renders is unit-tested across moving frames, density 2, structural changes, and equal-pixel repaints. On-device note: the Verse validation pass predates this change; the README checklist asks for a re-confirm of animations + Show. Co-Authored-By: Claude Fable 5 --- hosts/pocketbook/README.md | 28 +-- hosts/pocketbook/src/framebuffer.rs | 293 +++++++++++++++++++++------- hosts/pocketbook/src/main.rs | 28 +-- 3 files changed, 257 insertions(+), 92 deletions(-) diff --git a/hosts/pocketbook/README.md b/hosts/pocketbook/README.md index fd58bccd..f172f269 100644 --- a/hosts/pocketbook/README.md +++ b/hosts/pocketbook/README.md @@ -6,14 +6,17 @@ The PocketJS UI runtime on **PocketBook e-readers**, rendered through the It reuses the backend-agnostic `ui` surface (`pocket-ui-surface`) and the core's software rasterizer unchanged, then: -- rasterizes the DrawList to RGBA8 at `480×272 @2x` = 960×544 - (`pocketjs_core::raster::render_scaled`), matching the `pocketbook` target - profile in `contracts/spec/platforms.ts`; -- diffs frames in 16×16 tiles and blits the changed pixels as `RGB24` - (`framebuffer.rs`). inkview's `Screen::draw` converts `RGB24`→Gray8 - internally on **grayscale** panels (PocketBook Verse) and writes RGB directly - on **color** panels (PocketBook Era Color, Kaleido 3) — one blit path serves - both; +- rasterizes the DrawList **incrementally** to a retained RGBA8 buffer at + `480×272 @2x` = 960×544 (`pocketjs_core::raster::render_scaled_incremental` + with a core `DamageTracker`), matching the `pocketbook` target profile in + `contracts/spec/platforms.ts` — an idle frame costs zero raster work; +- pixel-diffs 16×16 tiles **inside the damage regions** and blits the changed + pixels as `RGB24` (`framebuffer.rs`). The DrawList damage bounds the raster + and the scan; the pixel diff trims the e-ink refresh to tiles that actually + changed (a DrawList edit that renders identical pixels flashes nothing). + inkview's `Screen::draw` converts `RGB24`→Gray8 internally on **grayscale** + panels (PocketBook Verse) and writes RGB directly on **color** panels + (PocketBook Era Color, Kaleido 3) — one blit path serves both; - drives the panel with a partial/dynamic/full update policy ported from `inkview-slint` (`refresh.rs`); - maps inkview keys → the spec BTN bitmask and the touchscreen → the framework's @@ -25,7 +28,7 @@ core's software rasterizer unchanged, then: The 960×544 render is integer-fit centered on the actual panel (which varies by model), so the host works across devices without per-model configuration. -See **`pocketjs-inkview-implementation.md`** at the repo root for the full +See **`docs/IMPLEMENTATION.md`** in this directory for the full design and the ground-truth API notes. ## Status @@ -83,9 +86,9 @@ bun pocket compile --target pocketbook --manifest apps/hero/pocket.json --projec Connect the PocketBook over USB, then from the repo root: ```sh -hosts/pocketbook/deploy.sh # auto-detects the mount point +bun hosts/pocketbook/deploy.ts # auto-detects the mount point # or explicitly: -hosts/pocketbook/deploy.sh /run/media/$USER/PB626 +bun hosts/pocketbook/deploy.ts /run/media/$USER/PB626 ``` It installs `applications/pocketjs-hero/{pocketjs-hero, app.js, app.pak}`. @@ -138,6 +141,9 @@ Run on both a grayscale and a color device to exercise both blit paths. scale-to-fit centering, @2x text, and animated partial updates all confirmed via photo + video. Input (touch / hardware keys), idle ghosting, and background-return still need a hands-on pass. + **Note:** that pass ran the full-raster pipeline; the raster has since + switched to core DrawList damage (`render_scaled_incremental`, pixel-parity + covered by unit tests) — re-confirm animations + Show on device. - **Era Color / Kaleido 3** — not yet tested (color blit path unverified). ### Logs diff --git a/hosts/pocketbook/src/framebuffer.rs b/hosts/pocketbook/src/framebuffer.rs index fc5777b1..ef0aaae9 100644 --- a/hosts/pocketbook/src/framebuffer.rs +++ b/hosts/pocketbook/src/framebuffer.rs @@ -1,10 +1,20 @@ -//! RGBA8 raster buffer + tile-based damage tracking for the e-ink panel. +//! Incremental RGBA8 raster + tile-based damage refinement for the e-ink panel. //! -//! The core's software rasterizer (`pocketjs_core::raster::render_scaled`) -//! produces an RGBA8 framebuffer at `logical × density` resolution, cleared to -//! opaque black with every pixel alpha=255. We diff it against the previous -//! frame in 16×16 tiles to drive partial panel updates, then blit the changed -//! pixels as `RGB24`. +//! Two damage layers compose here, each doing what the other cannot: +//! +//! 1. **DrawList damage** (`pocketjs_core::damage::DamageTracker`, via +//! `raster::render_scaled_incremental`): compares the retained DrawList +//! against the one whose pixels live in `rgba` and repaints only the +//! changed regions — rasterization cost scales with what changed, and an +//! idle frame costs nothing. +//! 2. **Pixel tile diff** (16×16, scoped to the damage regions): decides what +//! the *panel* must refresh. E-ink updates are the expensive resource, and +//! conservative DrawList regions can contain pixels that did not actually +//! change — the tile diff trims the refresh to real pixel changes. +//! +//! `rgba` is the persistent render target (always the complete current +//! frame — `blit_all` can present it at any time); `prev` mirrors what was +//! last diffed against, advanced only inside the damaged tiles. //! //! inkview's `Screen::draw` is generic over the pixel format and branches on //! the panel depth: on a grayscale panel (PocketBook Verse) it converts @@ -14,6 +24,7 @@ //! and color devices with no host-side conversion. use inkview::screen::{Screen, RGB24}; +use pocketjs_core::damage::{DamagePlan, DamagePolicy, DamageRect, DamageTracker}; use pocketjs_core::{raster, Ui}; use crate::Geometry; @@ -32,13 +43,15 @@ pub struct DirtyRect { const TILE: usize = 16; pub struct FramebufferPipeline { - /// Current frame, RGBA8 (raster output). + /// Persistent render target, RGBA8 — always the complete current frame. rgba: Vec, - /// Previous frame, RGBA8 (for damage diffing). + /// What the last diff ran against; advanced only inside dirty tiles. prev: Vec, w: usize, h: usize, density: u32, + /// DrawList snapshot for the pixels retained in `rgba`. + tracker: DamageTracker, } impl FramebufferPipeline { @@ -51,33 +64,66 @@ impl FramebufferPipeline { w, h, density, + tracker: DamageTracker::new(), } } - /// Rasterize the DrawList into the RGBA8 buffer at density scale. - /// `render_scaled` asserts the buffer is exactly `viewport×density` px. - pub fn rasterize(&mut self, ui: &Ui, words: &[u32]) { - raster::render_scaled(ui, words, &mut self.rgba, self.density); + /// Rasterize the DrawList into the retained RGBA8 buffer, repainting only + /// DrawList damage. Returns the logical damage plan; malformed DrawLists + /// conservatively fall back to a full render. + pub fn rasterize(&mut self, ui: &Ui, words: &[u32]) -> DamagePlan { + match raster::render_scaled_incremental( + ui, + words, + &mut self.rgba, + self.density, + &mut self.tracker, + DamagePolicy::default(), + ) { + Ok(plan) => plan, + Err(_) => { + raster::render_scaled(ui, words, &mut self.rgba, self.density); + self.tracker.invalidate(); + let logical = DamageRect::new( + 0, + 0, + (self.w / self.density as usize) as i32, + (self.h / self.density as usize) as i32, + ); + DamagePlan::full(logical) + } + } } - /// Diff the current frame against the previous one and return the changed - /// tiles (buffer coordinates). Does NOT advance the previous-frame buffer; - /// call [`Self::advance`] after blitting. - pub fn diff(&self) -> Vec { + /// Diff the current frame against `prev` inside the plan's regions and + /// return the changed tiles (buffer coordinates). Pixels outside the plan + /// are untouched by construction, so they are never scanned. Does NOT + /// advance `prev`; call [`Self::advance`] after blitting. + pub fn diff(&self, plan: &DamagePlan) -> Vec { + if plan.is_empty() { + return Vec::new(); + } let (w, h) = (self.w, self.h); let tx = w.div_ceil(TILE); let ty = h.div_ceil(TILE); let mut flags = vec![false; tx * ty]; - for y in 0..h { - for x in 0..w { - let i = (y * w + x) * 4; - // Alpha is always 255 (the raster clears opaque), so comparing - // RGB decides visibility; a 4-byte compare would be equivalent. - if self.rgba[i] != self.prev[i] - || self.rgba[i + 1] != self.prev[i + 1] - || self.rgba[i + 2] != self.prev[i + 2] - { - flags[(y / TILE) * tx + (x / TILE)] = true; + for region in plan.regions() { + let scale = self.density as usize; + let x0 = (region.x0.max(0) as usize * scale).min(w); + let y0 = (region.y0.max(0) as usize * scale).min(h); + let x1 = (region.x1.max(0) as usize * scale).min(w); + let y1 = (region.y1.max(0) as usize * scale).min(h); + for y in y0..y1 { + for x in x0..x1 { + let i = (y * w + x) * 4; + // Alpha is always 255 (the raster clears opaque), so + // comparing RGB decides visibility. + if self.rgba[i] != self.prev[i] + || self.rgba[i + 1] != self.prev[i + 1] + || self.rgba[i + 2] != self.prev[i + 2] + { + flags[(y / TILE) * tx + (x / TILE)] = true; + } } } } @@ -98,9 +144,22 @@ impl FramebufferPipeline { .collect() } - /// Latch the current frame as the previous one for the next diff. - pub fn advance(&mut self) { - std::mem::swap(&mut self.rgba, &mut self.prev); + /// Latch the blitted tiles into `prev`. Pixels outside `dirty` are equal + /// in both buffers already (unchanged, or repainted byte-identically). + pub fn advance(&mut self, dirty: &[DirtyRect]) { + for r in dirty { + for dy in 0..r.h { + let start = ((r.y + dy) * self.w + r.x) * 4; + let end = start + r.w * 4; + let (rgba, prev) = (&self.rgba[start..end], &mut self.prev[start..end]); + prev.copy_from_slice(rgba); + } + } + } + + /// Latch the complete frame (after a `blit_all` full presentation). + pub fn advance_full(&mut self) { + self.prev.copy_from_slice(&self.rgba); } /// Blit changed tiles to the inkview framebuffer, scaling through `geo`. @@ -126,7 +185,8 @@ impl FramebufferPipeline { } /// Full-buffer blit scaled through `geo` (used on Show / before a - /// full_update). + /// full_update). `rgba` is always the complete current frame, so a full + /// panel redraw needs no re-rasterization. pub fn blit_all(&self, screen: &mut Screen, geo: &Geometry) { for sy in geo.oy..(geo.oy + geo.disp_h) { let src_y = geo.screen_to_render_y(sy); @@ -146,54 +206,151 @@ impl FramebufferPipeline { #[cfg(test)] mod tests { use super::*; + use pocketjs_core::spec::draw_op; + + fn xy_word(x: i16, y: i16) -> u32 { + x as u16 as u32 | ((y as u16 as u32) << 16) + } + + fn wh_word(w: u16, h: u16) -> u32 { + w as u32 | ((h as u32) << 16) + } + + fn frame(x: i16, color: u32) -> Vec { + vec![ + draw_op::RECT, + xy_word(0, 0), + wh_word(64, 32), + 0xff20_1008, + draw_op::RECT, + xy_word(x, 4), + wh_word(6, 6), + color, + ] + } - fn solid(fb: &mut FramebufferPipeline, r: u8, g: u8, b: u8) { - for px in fb.rgba.chunks_exact_mut(4) { - px[0] = r; - px[1] = g; - px[2] = b; - px[3] = 255; + fn reference(ui: &Ui, words: &[u32], w: usize, h: usize, density: u32) -> Vec { + let mut full = vec![0u8; w * h * 4]; + raster::render_scaled(ui, words, &mut full, density); + full + } + + #[test] + fn incremental_pipeline_matches_full_renders_across_frames() { + let mut ui = Ui::new(); + ui.set_viewport(64.0, 32.0); + let mut fb = FramebufferPipeline::new(64, 32, 1); + let frames = [ + frame(2, 0xff00_00ff), + frame(20, 0xff00_ff00), + frame(40, 0xffff_0000), + ]; + for words in &frames { + let plan = fb.rasterize(&ui, words); + let dirty = fb.diff(&plan); + fb.advance(&dirty); + assert_eq!(fb.rgba, reference(&ui, words, 64, 32, 1)); + assert_eq!(fb.prev, fb.rgba, "prev latches every changed pixel"); } } #[test] - fn identical_frames_have_no_damage() { - let mut fb = FramebufferPipeline::new(32, 32, 1); - solid(&mut fb, 10, 20, 30); - fb.advance(); // prev = current - // Same content again → no diff. - solid(&mut fb, 10, 20, 30); - assert!(fb.diff().is_empty()); + fn unchanged_frames_produce_no_damage_and_no_dirty_tiles() { + let mut ui = Ui::new(); + ui.set_viewport(64.0, 32.0); + let mut fb = FramebufferPipeline::new(64, 32, 1); + let words = frame(2, 0xff00_00ff); + let plan = fb.rasterize(&ui, &words); + assert!(plan.is_full_redraw(), "first frame repaints everything"); + let dirty = fb.diff(&plan); + fb.advance(&dirty); + + let plan = fb.rasterize(&ui, &words); + assert!(plan.is_empty(), "unchanged DrawList → zero raster work"); + assert!(fb.diff(&plan).is_empty()); + } + + #[test] + fn dirty_tiles_are_scoped_to_the_damage_regions() { + let mut ui = Ui::new(); + ui.set_viewport(64.0, 32.0); + let mut fb = FramebufferPipeline::new(64, 32, 1); + let plan = fb.rasterize(&ui, &frame(2, 0xff00_00ff)); + let dirty = fb.diff(&plan); + fb.advance(&dirty); + + // Move the small rect: damage = old box ∪ new box, both in y 4..10. + let plan = fb.rasterize(&ui, &frame(40, 0xff00_00ff)); + assert!(!plan.is_full_redraw()); + let dirty = fb.diff(&plan); + assert!(!dirty.is_empty()); + for tile in &dirty { + assert!(tile.y < 16, "dirty tiles stay in the damaged band"); + } + fb.advance(&dirty); + assert_eq!(fb.prev, fb.rgba); } #[test] - fn color_change_is_damage_even_at_equal_luminance() { - // Diffing RGBA (not gray) means a hue shift at constant luminance is - // still reported — conservative (over-reports), which is safe: it only - // causes an extra blit, never a missed update. Matters for color panels. - let mut fb = FramebufferPipeline::new(16, 16, 1); - solid(&mut fb, 255, 0, 0); - fb.advance(); - solid(&mut fb, 0, 0, 255); - assert!(!fb.diff().is_empty()); + fn equal_pixels_inside_damage_produce_no_panel_refresh() { + // A DrawList change that renders identical pixels damages the region + // but must not dirty any tile — the pixel diff is what protects the + // e-ink panel from needless flashes. + let mut ui = Ui::new(); + ui.set_viewport(64.0, 32.0); + let mut fb = FramebufferPipeline::new(64, 32, 1); + let base = vec![ + draw_op::RECT, + xy_word(0, 0), + wh_word(64, 32), + 0xff10_2030, + draw_op::RECT, + xy_word(4, 4), + wh_word(8, 8), + 0xffff_ffff, + ]; + // Same pixels: the white rect painted twice (opaque over itself). + let repainted = vec![ + draw_op::RECT, + xy_word(0, 0), + wh_word(64, 32), + 0xff10_2030, + draw_op::RECT, + xy_word(4, 4), + wh_word(8, 8), + 0xffff_ffff, + draw_op::RECT, + xy_word(4, 4), + wh_word(8, 8), + 0xffff_ffff, + ]; + let plan = fb.rasterize(&ui, &base); + let dirty = fb.diff(&plan); + fb.advance(&dirty); + + // Structural change (op count differs) → conservative full replan, + // but the pixels are identical → zero dirty tiles, zero e-ink work. + let plan = fb.rasterize(&ui, &repainted); + assert!(plan.is_full_redraw()); + assert!(fb.diff(&plan).is_empty()); } #[test] - fn damage_reports_tile_bounds() { - let mut fb = FramebufferPipeline::new(32, 32, 1); - // prev = black; change one pixel in tile column 1 (x=16..31). - fb.advance(); - let i = (0 * 32 + 20) * 4; // y=0, x=20 → tile (1, 0) - fb.rgba[i] = 255; - fb.rgba[i + 3] = 255; - assert_eq!( - fb.diff(), - vec![DirtyRect { - x: 16, - y: 0, - w: 16, - h: 16 - }] - ); + fn density_two_scopes_damage_to_physical_pixels() { + let mut ui = Ui::new_with_raster_density(2); + ui.set_viewport(32.0, 16.0); + let mut fb = FramebufferPipeline::new(64, 32, 2); + let plan = fb.rasterize(&ui, &frame(1, 0xff00_00ff)); + let dirty = fb.diff(&plan); + fb.advance(&dirty); + + let words = frame(1, 0xff00_ff00); + let plan = fb.rasterize(&ui, &words); + assert!(!plan.is_full_redraw()); + let dirty = fb.diff(&plan); + assert!(!dirty.is_empty()); + fb.advance(&dirty); + assert_eq!(fb.rgba, reference(&ui, &words, 64, 32, 2)); + assert_eq!(fb.prev, fb.rgba); } } diff --git a/hosts/pocketbook/src/main.rs b/hosts/pocketbook/src/main.rs index 4135bac8..ab7c9121 100644 --- a/hosts/pocketbook/src/main.rs +++ b/hosts/pocketbook/src/main.rs @@ -3,8 +3,8 @@ //! Reuses the backend-agnostic `ui` surface (`pocket_ui_surface::UiSurface`) //! and the core's software rasterizer, then blits the frame as RGB24 (inkview //! converts to gray on grayscale panels, writes RGB on color panels). See -//! `pocketjs-inkview-implementation.md` -//! at the repo root for the full design and the ground-truth API notes. +//! `docs/IMPLEMENTATION.md` in this directory for the full design and the +//! ground-truth API notes. //! //! Event-loop model (mirrors `inkview-slint`): `iv_main` runs on the main //! thread forwarding every `Event` into an mpsc channel; a second thread owns @@ -214,32 +214,34 @@ fn tick( surface.tick(); - // Rasterize at density → RGBA8 and diff against the previous frame - // (buffer coords). The blit below reads the CURRENT frame, so the - // previous-frame latch is advanced only after blitting. + // Incremental raster: repaint only DrawList damage into the retained + // RGBA8 target, then pixel-diff WITHIN the damage plan to find the tiles + // the panel must refresh. An idle frame plans nothing, rasterizes + // nothing, and scans nothing. let dirty = surface.with_ui(|ui| { let words = ui.draw().words.clone(); - fb.rasterize(ui, &words); - fb.diff() + let plan = fb.rasterize(ui, &words); + fb.diff(&plan) }); if full { - // Full redraw (first paint / return from background): blit everything + // Full panel redraw (first paint / return from background): the + // retained buffer is always the complete current frame, so re-blit it // and flash the panel once for a clean, ghost-free image. fb.blit_all(screen, geo); refresh.full(screen); + fb.advance_full(); } else if !dirty.is_empty() { fb.blit_dirty(screen, &dirty, geo); let screen_dirty = offset_rects(&dirty, geo); refresh.present(screen, &screen_dirty); + // Latch only the blitted tiles; everything else already matches. + fb.advance(&dirty); } else { - // No change this frame; still let the refresh policy run its quiet - // cleanup timer (it no-ops when there's nothing pending). + // No pixel change this frame; still let the refresh policy run its + // quiet cleanup timer (it no-ops when there's nothing pending). refresh.present(screen, &[]); } - - // Latch this frame as the previous one for the next diff. - fb.advance(); Ok(()) } From 7977a829f385eba06b369927c92d7857ac5c7936 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:57:26 +0800 Subject: [PATCH 13/13] fix(pocketbook): take hostAbi 5 and flag the host work-in-progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #176 landed symbian-e7-dev on hostAbi 4 while this branch was open, so two contracts claimed the same wire generation. assertNativeHostContract checks (target, abi) so nothing mis-binds, but the number is a registry-wide sequence — the incoming target yields to 5 (psp=1, vita=2, macos-widget=3, symbian-e7-dev=4). Also register pocketbook in tests/symbian-runtime.test.ts (a second exhaustive target-key assertion) and record the on-device re-confirm plus the WIP status in the README. Co-Authored-By: Claude Opus 5 --- contracts/spec/platforms.ts | 2 +- hosts/pocketbook/README.md | 14 ++++++++------ hosts/pocketbook/docs/IMPLEMENTATION.md | 3 ++- hosts/pocketbook/src/main.rs | 2 +- tests/symbian-runtime.test.ts | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index 9619e251..7886a286 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -212,7 +212,7 @@ export const POCKET_TARGETS = defineTargetRegistry` to the type): pocketbook: { - hostAbi: 4, // next free ABI (psp=1, vita=2, macos-widget=3) + hostAbi: 5, // next free ABI (psp=1, vita=2, macos-widget=3, + // symbian-e7-dev=4) platform: "pocketbook", form: "takeover", display: { diff --git a/hosts/pocketbook/src/main.rs b/hosts/pocketbook/src/main.rs index ab7c9121..2936f08e 100644 --- a/hosts/pocketbook/src/main.rs +++ b/hosts/pocketbook/src/main.rs @@ -29,7 +29,7 @@ use framebuffer::DirtyRect; /// contracts/spec/platforms.ts, or plan-built bundles refuse this host /// (framework/src/host.ts::assertNativeHostContract). const HOST_ID: &str = "pocketbook"; -const HOST_ABI: u32 = 4; +const HOST_ABI: u32 = 5; /// Logical tick cadence. E-ink doesn't need 60 fps; ~30 fps keeps animations /// smooth while sparing CPU and battery. diff --git a/tests/symbian-runtime.test.ts b/tests/symbian-runtime.test.ts index 8886ce80..f2776096 100644 --- a/tests/symbian-runtime.test.ts +++ b/tests/symbian-runtime.test.ts @@ -14,7 +14,7 @@ const repository = new URL("..", import.meta.url).pathname; describe("experimental Nokia E7 runtime profile", () => { test("does not register an unproven production target", () => { - expect(Object.keys(POCKET_TARGETS)).toEqual(["psp", "vita", "macos-widget"]); + expect(Object.keys(POCKET_TARGETS)).toEqual(["psp", "vita", "pocketbook", "macos-widget"]); expect(POCKET_TARGETS).not.toHaveProperty(SYMBIAN_E7_DEV_TARGET_ID); expect(SYMBIAN_E7_DEV_HOST_ABI).toBe(4); });