diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index 72a2bf8b..7886a286 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,27 @@ export const POCKET_TARGETS = defineTargetRegistry 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(); 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}; diff --git a/hosts/pocketbook/Cargo.lock b/hosts/pocketbook/Cargo.lock new file mode 100644 index 00000000..ed3ac59f --- /dev/null +++ b/hosts/pocketbook/Cargo.lock @@ -0,0 +1,813 @@ +# 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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "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]] +name = "cfg-if" +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 1.3.2", + "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 = "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" +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 = "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" +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 = "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" +source = "git+https://github.com/simmsb/inkview-rs?rev=0e4ecab93f6e2907c255bb50bc4b6c1882779274#0e4ecab93f6e2907c255bb50bc4b6c1882779274" +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 = "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" +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 = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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 = "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" +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", + "cc", + "env_logger", + "inkview", + "log", + "pocket-mod", + "pocket-ui-surface", + "pocketjs-core", + "rquickjs", +] + +[[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 = "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" +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", + "rquickjs-macro", +] + +[[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-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" +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 = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "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" +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", +] + +[[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 new file mode 100644 index 00000000..f4043956 --- /dev/null +++ b/hosts/pocketbook/Cargo.toml @@ -0,0 +1,53 @@ +# 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 blits the frame as RGB24 (inkview +# converts to gray on grayscale panels, writes RGB on color panels). +# See docs/IMPLEMENTATION.md in this directory. +# +# 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), 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" +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 +strip = true +panic = "abort" diff --git a/hosts/pocketbook/README.md b/hosts/pocketbook/README.md new file mode 100644 index 00000000..33c5dd6b --- /dev/null +++ b/hosts/pocketbook/README.md @@ -0,0 +1,160 @@ +# 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 **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 + 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`, 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 **`docs/IMPLEMENTATION.md`** in this directory for the full +design and the ground-truth API notes. + +## Status + +**Work in progress** — merged early so it can be iterated on in-tree. 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`). **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 + +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 +``` + +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 +bun hosts/pocketbook/deploy.ts # auto-detects the mount point +# or explicitly: +bun hosts/pocketbook/deploy.ts /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 | +| `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** + +- [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 + 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** + +- [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. + +### 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, **re-confirmed after the switch to incremental DrawList + damage** (`render_scaled_incremental`). The progress-bar animation now runs + at its intended cadence — closer to the desktop host — because the tick loop + no longer re-rasterizes and re-scans the whole 960×544 frame every 33 ms. + 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. 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/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/hosts/pocketbook/docs/IMPLEMENTATION.md b/hosts/pocketbook/docs/IMPLEMENTATION.md new file mode 100644 index 00000000..fe96629e --- /dev/null +++ b/hosts/pocketbook/docs/IMPLEMENTATION.md @@ -0,0 +1,875 @@ +# PocketJS × inkview — Implementation File + +> **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`). +> +> 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: 5, // next free ABI (psp=1, vita=2, macos-widget=3, + // symbian-e7-dev=4) + 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/hosts/pocketbook/docs/INTEGRATION.md b/hosts/pocketbook/docs/INTEGRATION.md new file mode 100644 index 00000000..6c120093 --- /dev/null +++ b/hosts/pocketbook/docs/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 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; +} diff --git a/hosts/pocketbook/src/framebuffer.rs b/hosts/pocketbook/src/framebuffer.rs new file mode 100644 index 00000000..ef0aaae9 --- /dev/null +++ b/hosts/pocketbook/src/framebuffer.rs @@ -0,0 +1,356 @@ +//! Incremental RGBA8 raster + tile-based damage refinement for the e-ink panel. +//! +//! 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 +//! `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::{Screen, RGB24}; +use pocketjs_core::damage::{DamagePlan, DamagePolicy, DamageRect, DamageTracker}; +use pocketjs_core::{raster, Ui}; + +use crate::Geometry; + +/// 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 { + /// Persistent render target, RGBA8 — always the complete current frame. + rgba: Vec, + /// 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 { + /// `w`/`h` are the RENDER-buffer dimensions (logical × density). + pub fn new(w: usize, h: usize, density: u32) -> Self { + let n = w * h * 4; + Self { + rgba: vec![0u8; n], + prev: vec![0u8; n], + w, + h, + density, + tracker: DamageTracker::new(), + } + } + + /// 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 `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 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; + } + } + } + } + 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() + } + + /// 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`. + /// Issues no panel update — the caller drives that through `refresh`. + pub fn blit_dirty(&self, screen: &mut Screen, dirty: &[DirtyRect], geo: &Geometry) { + for r in dirty { + 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( + sx, + sy, + RGB24(self.rgba[i], self.rgba[i + 1], self.rgba[i + 2]), + ); + } + } + } + } + + /// Full-buffer blit scaled through `geo` (used on Show / before a + /// 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); + 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( + sx, + sy, + RGB24(self.rgba[i], self.rgba[i + 1], self.rgba[i + 2]), + ); + } + } + } +} + +#[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 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 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 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 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/input.rs b/hosts/pocketbook/src/input.rs new file mode 100644 index 00000000..9cfb9872 --- /dev/null +++ b/hosts/pocketbook/src/input.rs @@ -0,0 +1,173 @@ +//! 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::{btn, ANALOG_CENTER}; + +/// 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)>, + 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(ox: i32, oy: i32, logical_w: u32, logical_h: u32, disp_w: u32, disp_h: u32) -> Self { + Self { + buttons: 0, + touch: None, + ox, + oy, + logical_w, + logical_h, + disp_w, + disp_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). + /// Maps through the displayed area: screen → render → logical. + fn to_logical(&self, x: i32, y: i32) -> (u32, 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) + } + + /// (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(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); + input.apply_key_up(Key::Ok); + assert_eq!(input.snapshot().0 & btn::CROSS, 0); + } + + #[test] + fn pointer_maps_physical_to_logical() { + // 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) * 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 { + 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..2936f08e --- /dev/null +++ b/hosts/pocketbook/src/main.rs @@ -0,0 +1,362 @@ +//! 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 blits the frame as RGB24 (inkview +//! converts to gray on grayscale panels, writes RGB on color panels). See +//! `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 +//! 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 = 5; + +/// 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; + +/// 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(); + + // &'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::for_panel(phys_w, phys_h); + log::info!( + "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 + ); + + // 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.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. + 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(); + + // 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(); + let plan = fb.rasterize(ui, &words); + fb.diff(&plan) + }); + + if full { + // 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 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, &[]); + } + Ok(()) +} + +/// Render-buffer rects → screen rects via the geometry mapping. +fn offset_rects(rects: &[DirtyRect], geo: &Geometry) -> Vec { + rects + .iter() + .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() +} + +/// 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, + /// 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) 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 (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 { + 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, + } + } + } +} diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 60b6e584..7eec6a06 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/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); }); diff --git a/tools/pocket.ts b/tools/pocket.ts index f5b37f3f..313cb92e 100644 --- a/tools/pocket.ts +++ b/tools/pocket.ts @@ -157,6 +157,17 @@ const targetBackends = { "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]({